From f92648c329eaeb1956421f955a3d55c3bf185a6a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:15:56 +0000 Subject: [PATCH 01/17] CI: run the required checks for GitHub merge queue groups PHPUnit Feature, Shared Server fixtures, SonarQube and Public Boundary now also run on merge_group, so a merge queue can replace the require-up-to-date rerun loop. Base-revision checks use the queue group's base_sha, the boundary scan covers the group's exact base..head range, and the push trigger skips gh-readonly-queue/** branches that the merge_group run already scans. Shared Server fixtures has no path filter for merge_group, so every queued group gets the full profile. --- .github/workflows/phpunit-feature.yml | 9 +++++---- .github/workflows/public-boundary.yml | 6 ++++++ .github/workflows/server-parity.yml | 1 + .github/workflows/sonar.yml | 1 + 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 9cecaf1bf..2b0bd2998 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -5,6 +5,7 @@ on: branches: - main pull_request: + merge_group: workflow_dispatch: inputs: corpus_base_ref: @@ -85,7 +86,7 @@ jobs: - name: Require versioned worker OpenAPI evolution env: - OPENAPI_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} + OPENAPI_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} run: php scripts/ci/check-worker-openapi-evolution.php "$OPENAPI_BASE_REF" - name: Audit locked PHP dependencies @@ -93,7 +94,7 @@ jobs: - name: Require durable codec evidence env: - CORPUS_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} + CORPUS_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} run: | set -euo pipefail if [[ ! "$CORPUS_BASE_REF" =~ ^[0-9a-f]{40}$ ]] || [[ "$CORPUS_BASE_REF" =~ ^0+$ ]]; then @@ -167,7 +168,7 @@ jobs: - name: Run feature, Nexus, and regression corpus suites shell: bash env: - CORPUS_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} + CORPUS_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} run: | set -euo pipefail if [[ ! "$CORPUS_BASE_REF" =~ ^[0-9a-f]{40}$ ]] || [[ "$CORPUS_BASE_REF" =~ ^0+$ ]]; then @@ -262,7 +263,7 @@ jobs: - name: Select production image qualification for changed build inputs id: image_inputs run: | - if git diff --quiet '${{ github.event.pull_request.base.sha || github.event.before || inputs.corpus_base_ref }}' HEAD -- Dockerfile Dockerfile.dockerignore .dockerignore composer.json composer.lock ':(glob)docker/**'; then + if git diff --quiet '${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || inputs.corpus_base_ref }}' HEAD -- Dockerfile Dockerfile.dockerignore .dockerignore composer.json composer.lock ':(glob)docker/**'; then echo 'required=false' >> "$GITHUB_OUTPUT" else echo 'required=true' >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/public-boundary.yml b/.github/workflows/public-boundary.yml index 89bfb4a83..16b048f75 100644 --- a/.github/workflows/public-boundary.yml +++ b/.github/workflows/public-boundary.yml @@ -2,7 +2,11 @@ name: Public Boundary on: pull_request: + merge_group: + # A merge-queue branch is scanned by its merge_group run, over the exact range. push: + branches-ignore: + - gh-readonly-queue/** workflow_dispatch: permissions: @@ -28,6 +32,8 @@ jobs: if [[ "${{ github.event_name }}" == "pull_request" ]]; then range="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + elif [[ "${{ github.event_name }}" == "merge_group" ]]; then + range="${{ github.event.merge_group.base_sha }}..${{ github.event.merge_group.head_sha }}" elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then range="-1 ${{ github.sha }}" elif [[ "${{ github.event.before }}" =~ ^0+$ ]]; then diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 9960470e7..bef4bc698 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -112,6 +112,7 @@ on: - tests/Support/ServerParityAdmissionObservation.mjs - tests/Support/ServerParityVisibilityObservation.mjs - rust/** + merge_group: workflow_dispatch: # A newer push to the same pull request supersedes the run in progress, so a diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 8aad74cac..785953ceb 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + merge_group: permissions: contents: read From afd771237d817d8ce7fdb9f9fff2914009cc149a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:22:15 +0000 Subject: [PATCH 02/17] CI: contract test for the merge queue triggers tests/Unit/MergeGroupTriggersTest.mjs checks that PHPUnit Feature, Shared Server fixtures, SonarQube and Public Boundary run on merge_group, that base-revision checks and the boundary range use the queue group's base and head, that queue branches are not scanned twice, and that superseded-run cancellation never applies to a queue group. It runs in Repository contracts. It fails 6 of 7 checks on the train head without merge_group support. --- .github/workflows/phpunit-feature.yml | 3 ++ tests/Unit/MergeGroupTriggersTest.mjs | 41 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/Unit/MergeGroupTriggersTest.mjs diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 2b0bd2998..1c7809356 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -72,6 +72,9 @@ jobs: - name: Require safe PHP image build context run: node --test tests/Unit/PhpImageContextTest.mjs + - name: Run the required checks for merge queue groups + run: node --test tests/Unit/MergeGroupTriggersTest.mjs + - name: Validate embedded Workflow package authority run: php scripts/ci/test-workflow-package-authority.php diff --git a/tests/Unit/MergeGroupTriggersTest.mjs b/tests/Unit/MergeGroupTriggersTest.mjs new file mode 100644 index 000000000..fef717fc9 --- /dev/null +++ b/tests/Unit/MergeGroupTriggersTest.mjs @@ -0,0 +1,41 @@ +// The checks a GitHub merge queue waits for must run on merge_group, and +// queue branches must not be scanned twice. Source-only: enabling the queue +// is a separate repository setting. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; + +const workflow = name => readFileSync(`.github/workflows/${name}`, 'utf8'); +// The top-level `on:` block, up to the next top-level key. +const triggers = text => { + const start = text.indexOf('\non:\n'); + assert.notEqual(start, -1, 'workflow has an on: block'); + const rest = text.slice(start + 5); + const end = rest.search(/\n[a-z_-]+:/); + return end === -1 ? rest : rest.slice(0, end); +}; + +for (const name of ['phpunit-feature.yml', 'server-parity.yml', 'sonar.yml', 'public-boundary.yml']) { + test(`${name} runs for merge queue groups`, () => { + assert.match(triggers(workflow(name)), /^ {2}merge_group:\s*$/m); + }); +} + +test('PHPUnit Feature diffs a queue group against its base', () => { + const text = workflow('phpunit-feature.yml'); + const bases = text.match(/github\.event_name == 'merge_group' && github\.event\.merge_group\.base_sha/g) ?? []; + assert.equal(bases.length, 3, 'OpenAPI and both corpus base revisions'); + assert.match(text, /github\.event\.pull_request\.base\.sha \|\| github\.event\.merge_group\.base_sha \|\| github\.event\.before/); +}); + +test('Public Boundary scans a queue group over its exact range, once', () => { + const text = workflow('public-boundary.yml'); + assert.match(text, /"merge_group" \]\]; then\n\s+range="\$\{\{ github\.event\.merge_group\.base_sha \}\}\.\.\$\{\{ github\.event\.merge_group\.head_sha \}\}"/); + assert.match(triggers(text), /^ {2}push:\n {4}branches-ignore:\n {6}- gh-readonly-queue\/\*\*$/m); +}); + +test('superseded-run cancellation never cancels a queue group', () => { + for (const name of ['phpunit-feature.yml', 'server-parity.yml', 'sonar.yml']) { + assert.match(workflow(name), /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/, name); + } +}); From 2ba8c47f30a4749274c4a9a46c403e7a34c89d75 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:40:04 +0000 Subject: [PATCH 03/17] Expect the merge-queue base revision in the PHPUnit Feature contracts The OpenAPI evolution and regression corpus contracts pin the exact base revision expression; include the merge_group base so a queued group diffs against the revision it was built on. --- tests/Unit/OpenApiDocumentEvolutionTest.php | 2 +- tests/Unit/PhpunitFeatureWorkflowContractTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/OpenApiDocumentEvolutionTest.php b/tests/Unit/OpenApiDocumentEvolutionTest.php index 135121125..97499930c 100644 --- a/tests/Unit/OpenApiDocumentEvolutionTest.php +++ b/tests/Unit/OpenApiDocumentEvolutionTest.php @@ -55,7 +55,7 @@ public function test_candidate_structure_and_feature_ci_compare_to_the_base_revi $this->assertCount(1, $preflightStep); $this->assertSame( - '${{ github.event_name == \'pull_request\' && github.event.pull_request.base.sha || github.event_name == \'push\' && github.event.before || inputs.corpus_base_ref }}', + '${{ github.event_name == \'pull_request\' && github.event.pull_request.base.sha || github.event_name == \'merge_group\' && github.event.merge_group.base_sha || github.event_name == \'push\' && github.event.before || inputs.corpus_base_ref }}', $preflightStep[0]['env']['OPENAPI_BASE_REF'] ?? null, ); $this->assertSame( diff --git a/tests/Unit/PhpunitFeatureWorkflowContractTest.php b/tests/Unit/PhpunitFeatureWorkflowContractTest.php index 14c48570a..b404d7c95 100644 --- a/tests/Unit/PhpunitFeatureWorkflowContractTest.php +++ b/tests/Unit/PhpunitFeatureWorkflowContractTest.php @@ -106,7 +106,7 @@ public function test_regression_corpus_validation_uses_git_capable_ci_and_an_exa $this->assertSame(0, $checkout['with']['fetch-depth'] ?? null); $this->assertFalse($checkout['with']['persist-credentials'] ?? null); $this->assertSame( - '${{ github.event_name == \'pull_request\' && github.event.pull_request.base.sha || github.event_name == \'push\' && github.event.before || inputs.corpus_base_ref }}', + '${{ github.event_name == \'pull_request\' && github.event.pull_request.base.sha || github.event_name == \'merge_group\' && github.event.merge_group.base_sha || github.event_name == \'push\' && github.event.before || inputs.corpus_base_ref }}', $execution['env']['CORPUS_BASE_REF'] ?? null, ); $this->assertArrayNotHasKey('if', $execution); From 2bc50deb80dadcf72965eefb1072ba60f9a7748d Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:42:30 +0000 Subject: [PATCH 04/17] Keep SonarQube and unfiltered push scans out of the merge-queue change The Sonar job receives SONAR_TOKEN and its fork guard is always true for a merge group, so it no longer runs on merge_group; the contract test now requires that. Public Boundary scans every push again, including queue branches, and the test requires the unfiltered push trigger alongside the exact merge-group range. --- .github/workflows/public-boundary.yml | 3 --- .github/workflows/sonar.yml | 1 - tests/Unit/MergeGroupTriggersTest.mjs | 22 +++++++++++++++------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/public-boundary.yml b/.github/workflows/public-boundary.yml index 16b048f75..763b55446 100644 --- a/.github/workflows/public-boundary.yml +++ b/.github/workflows/public-boundary.yml @@ -3,10 +3,7 @@ name: Public Boundary on: pull_request: merge_group: - # A merge-queue branch is scanned by its merge_group run, over the exact range. push: - branches-ignore: - - gh-readonly-queue/** workflow_dispatch: permissions: diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 785953ceb..8aad74cac 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -5,7 +5,6 @@ on: branches: [main] pull_request: branches: [main] - merge_group: permissions: contents: read diff --git a/tests/Unit/MergeGroupTriggersTest.mjs b/tests/Unit/MergeGroupTriggersTest.mjs index fef717fc9..e8efba169 100644 --- a/tests/Unit/MergeGroupTriggersTest.mjs +++ b/tests/Unit/MergeGroupTriggersTest.mjs @@ -1,6 +1,7 @@ -// The checks a GitHub merge queue waits for must run on merge_group, and -// queue branches must not be scanned twice. Source-only: enabling the queue -// is a separate repository setting. +// The Actions checks a GitHub merge queue waits for must run on merge_group. +// Source-only: enabling the queue is a separate repository setting, and its +// Sonar gate and terminal Shared Server fixtures result are separate +// prerequisites (Server #725). import test from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; @@ -15,12 +16,18 @@ const triggers = text => { return end === -1 ? rest : rest.slice(0, end); }; -for (const name of ['phpunit-feature.yml', 'server-parity.yml', 'sonar.yml', 'public-boundary.yml']) { +for (const name of ['phpunit-feature.yml', 'server-parity.yml', 'public-boundary.yml']) { test(`${name} runs for merge queue groups`, () => { assert.match(triggers(workflow(name)), /^ {2}merge_group:\s*$/m); }); } +// The Sonar job receives SONAR_TOKEN and its fork guard is always true for a +// merge group, so candidate source must not reach it through the queue. +test('SonarQube does not run for merge queue groups', () => { + assert.doesNotMatch(triggers(workflow('sonar.yml')), /merge_group/); +}); + test('PHPUnit Feature diffs a queue group against its base', () => { const text = workflow('phpunit-feature.yml'); const bases = text.match(/github\.event_name == 'merge_group' && github\.event\.merge_group\.base_sha/g) ?? []; @@ -28,14 +35,15 @@ test('PHPUnit Feature diffs a queue group against its base', () => { assert.match(text, /github\.event\.pull_request\.base\.sha \|\| github\.event\.merge_group\.base_sha \|\| github\.event\.before/); }); -test('Public Boundary scans a queue group over its exact range, once', () => { +test('Public Boundary scans a queue group over its exact range and still scans every push', () => { const text = workflow('public-boundary.yml'); assert.match(text, /"merge_group" \]\]; then\n\s+range="\$\{\{ github\.event\.merge_group\.base_sha \}\}\.\.\$\{\{ github\.event\.merge_group\.head_sha \}\}"/); - assert.match(triggers(text), /^ {2}push:\n {4}branches-ignore:\n {6}- gh-readonly-queue\/\*\*$/m); + assert.match(triggers(text), /^ {2}push:\s*$/m); + assert.doesNotMatch(triggers(text), /branches-ignore|branches:|paths/); }); test('superseded-run cancellation never cancels a queue group', () => { - for (const name of ['phpunit-feature.yml', 'server-parity.yml', 'sonar.yml']) { + for (const name of ['phpunit-feature.yml', 'server-parity.yml']) { assert.match(workflow(name), /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/, name); } }); From 3cf93e4e05125a311acc64499f1e284e3cda3b76 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:13:01 +0000 Subject: [PATCH 05/17] Check queue-group cancellation only where a workflow has a concurrency group #723 removed PHPUnit Feature's concurrency group, because its required gate runs with always() under the central action policy. The test now requires that any cancel-in-progress in a workflow the queue waits for cancels only pull request runs, and that Shared Server fixtures keeps its pull-request-only cancellation. --- tests/Unit/MergeGroupTriggersTest.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/Unit/MergeGroupTriggersTest.mjs b/tests/Unit/MergeGroupTriggersTest.mjs index e8efba169..b4897d693 100644 --- a/tests/Unit/MergeGroupTriggersTest.mjs +++ b/tests/Unit/MergeGroupTriggersTest.mjs @@ -42,8 +42,14 @@ test('Public Boundary scans a queue group over its exact range and still scans e assert.doesNotMatch(triggers(text), /branches-ignore|branches:|paths/); }); +// A workflow the queue waits for may cancel only a superseded pull request +// run. PHPUnit Feature has no concurrency group (its required gate runs with +// always(), per the central action policy), so it never cancels anything. test('superseded-run cancellation never cancels a queue group', () => { - for (const name of ['phpunit-feature.yml', 'server-parity.yml']) { - assert.match(workflow(name), /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/, name); + for (const name of ['phpunit-feature.yml', 'server-parity.yml', 'public-boundary.yml']) { + for (const line of workflow(name).match(/^ *cancel-in-progress:.*$/gm) ?? []) { + assert.match(line, /^ {2}cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}$/, name); + } } + assert.match(workflow('server-parity.yml'), /^ {2}cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}$/m); }); From 5e75436170001f9c734c5c939c3186ddc8897bcc Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:22:19 +0000 Subject: [PATCH 06/17] CI: run the SDK direction profiles in three concurrent lanes In each database job of Shared Server fixtures, the nine published-SDK direction profiles ran one after another (two pairs already overlapped), about 22 of the job's 64-72 minutes (train #729, PostgreSQL 17 job 120866907013: 12:19:50 to 12:41:36). Each profile already has its own containers, PHP and embedded databases, evidence and state directory, and its own workflow-ID prefix on the shared native server. sdk-direction-lanes.sh now runs them in three lanes balanced by those durations, in order within a lane. Every profile runs even after another fails; the step fails if any did, after printing each profile's log in its own group. The job's existing cleanup steps still stop and remove every profile's containers. Expected saving: about 14 minutes per database job. --- .github/workflows/server-parity.yml | 129 +----------------- .../server-parity/sdk-direction-lanes.sh | 49 +++++++ .../server-parity/sdk-directions/README.md | 2 + 3 files changed, 57 insertions(+), 123 deletions(-) create mode 100755 scripts/conformance/server-parity/sdk-direction-lanes.sh diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 81878ea9f..ca03000ff 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -610,47 +610,8 @@ jobs: bash scripts/conformance/server-parity/legacy-marker-profile.sh mysql http://parity-php:8080 http://parity-native-a:8080 bash scripts/conformance/server-parity/range-repair-profile.sh mysql http://parity-native-a:8080 bash scripts/conformance/server-parity/range-repair-profile.sh mysql http://parity-native-a:8080 pending-native - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 child - docker stop --time 30 "$RESOURCE_SCOPE-sdk-child-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 timer - docker stop --time 30 "$RESOURCE_SCOPE-sdk-timers-server" - # Separate queues/databases/evidence; preserve statuses even after children exit. - ( - signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 signal > parity-evidence/sdk-signals-profile.log 2>&1 || signal_status=$? - printf '%s\n' "$signal_status" > parity-evidence/sdk-signals-profile.status - ) & - ( - query_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 query > parity-evidence/sdk-queries-profile.log 2>&1 || query_status=$? - printf '%s\n' "$query_status" > parity-evidence/sdk-queries-profile.status - ) & - update_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 update || update_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-updates-server" || true - failure_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 update-failure || failure_status=$? - wait - signal_status=$(cat parity-evidence/sdk-signals-profile.status) - query_status=$(cat parity-evidence/sdk-queries-profile.status) - cat parity-evidence/sdk-signals-profile.log parity-evidence/sdk-queries-profile.log - for completed_profile in sdk-signals sdk-queries sdk-update-failures; do - docker stop --time 30 "$RESOURCE_SCOPE-$completed_profile-server" || true - done - namespace_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 namespace-signal || namespace_signal_status=$? - test "$signal_status" -eq 0 - test "$query_status" -eq 0 - test "$update_status" -eq 0 - test "$failure_status" -eq 0 - test "$namespace_signal_status" -eq 0 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-namespace-signals-server" || true - selected_run_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 selected-run-signal || selected_run_signal_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-selected-run-signals-server" || true - test "$selected_run_signal_status" -eq 0 + # Every SDK direction profile, in three concurrent lanes. + bash scripts/conformance/server-parity/sdk-direction-lanes.sh mysql http://parity-native-a:8080 docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint sh \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DW_PARITY_TOKEN=parity-test-token "$image" -ec ' @@ -1123,47 +1084,8 @@ jobs: bash scripts/conformance/server-parity/legacy-marker-profile.sh pgsql http://parity-php:8080 http://parity-native-a:8080 bash scripts/conformance/server-parity/range-repair-profile.sh pgsql http://parity-native-a:8080 bash scripts/conformance/server-parity/range-repair-profile.sh pgsql http://parity-native-a:8080 pending-native - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 child - docker stop --time 30 "$RESOURCE_SCOPE-sdk-child-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 timer - docker stop --time 30 "$RESOURCE_SCOPE-sdk-timers-server" - # Separate queues/databases/evidence; preserve statuses even after children exit. - ( - signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 signal > parity-evidence/sdk-signals-profile.log 2>&1 || signal_status=$? - printf '%s\n' "$signal_status" > parity-evidence/sdk-signals-profile.status - ) & - ( - query_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 query > parity-evidence/sdk-queries-profile.log 2>&1 || query_status=$? - printf '%s\n' "$query_status" > parity-evidence/sdk-queries-profile.status - ) & - update_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 update || update_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-updates-server" || true - failure_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 update-failure || failure_status=$? - wait - signal_status=$(cat parity-evidence/sdk-signals-profile.status) - query_status=$(cat parity-evidence/sdk-queries-profile.status) - cat parity-evidence/sdk-signals-profile.log parity-evidence/sdk-queries-profile.log - for completed_profile in sdk-signals sdk-queries sdk-update-failures; do - docker stop --time 30 "$RESOURCE_SCOPE-$completed_profile-server" || true - done - namespace_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 namespace-signal || namespace_signal_status=$? - test "$signal_status" -eq 0 - test "$query_status" -eq 0 - test "$update_status" -eq 0 - test "$failure_status" -eq 0 - test "$namespace_signal_status" -eq 0 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-namespace-signals-server" || true - selected_run_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 selected-run-signal || selected_run_signal_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-selected-run-signals-server" || true - test "$selected_run_signal_status" -eq 0 + # Every SDK direction profile, in three concurrent lanes. + bash scripts/conformance/server-parity/sdk-direction-lanes.sh pgsql http://parity-native-a:8080 docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint sh \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DW_PARITY_TOKEN=parity-test-token "$image" -ec ' @@ -1628,47 +1550,8 @@ jobs: bash scripts/conformance/server-parity/legacy-marker-profile.sh sqlite http://parity-server:8080 http://parity-rust:8080 bash scripts/conformance/server-parity/range-repair-profile.sh sqlite http://parity-rust:8080 bash scripts/conformance/server-parity/range-repair-profile.sh sqlite http://parity-rust:8080 pending-native - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 child - docker stop --time 30 "$RESOURCE_SCOPE-sdk-child-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 timer - docker stop --time 30 "$RESOURCE_SCOPE-sdk-timers-server" - # Separate queues/databases/evidence; preserve statuses even after children exit. - ( - signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 signal > parity-evidence/sdk-signals-profile.log 2>&1 || signal_status=$? - printf '%s\n' "$signal_status" > parity-evidence/sdk-signals-profile.status - ) & - ( - query_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 query > parity-evidence/sdk-queries-profile.log 2>&1 || query_status=$? - printf '%s\n' "$query_status" > parity-evidence/sdk-queries-profile.status - ) & - update_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 update || update_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-updates-server" || true - failure_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 update-failure || failure_status=$? - wait - signal_status=$(cat parity-evidence/sdk-signals-profile.status) - query_status=$(cat parity-evidence/sdk-queries-profile.status) - cat parity-evidence/sdk-signals-profile.log parity-evidence/sdk-queries-profile.log - for completed_profile in sdk-signals sdk-queries sdk-update-failures; do - docker stop --time 30 "$RESOURCE_SCOPE-$completed_profile-server" || true - done - namespace_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 namespace-signal || namespace_signal_status=$? - test "$signal_status" -eq 0 - test "$query_status" -eq 0 - test "$update_status" -eq 0 - test "$failure_status" -eq 0 - test "$namespace_signal_status" -eq 0 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-namespace-signals-server" || true - selected_run_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 selected-run-signal || selected_run_signal_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-selected-run-signals-server" || true - test "$selected_run_signal_status" -eq 0 + # Every SDK direction profile, in three concurrent lanes. + bash scripts/conformance/server-parity/sdk-direction-lanes.sh sqlite http://parity-rust:8080 docker run --rm --user=1000:1000 --entrypoint php --network "$RESOURCE_SCOPE" \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DW_PARITY_TOKEN=parity-test-token "$image" scripts/conformance/server-parity/restart.php \ diff --git a/scripts/conformance/server-parity/sdk-direction-lanes.sh b/scripts/conformance/server-parity/sdk-direction-lanes.sh new file mode 100755 index 000000000..87f05cd88 --- /dev/null +++ b/scripts/conformance/server-parity/sdk-direction-lanes.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Run every published-SDK direction profile against one native server, in +# three concurrent lanes. Each profile already has its own containers, PHP and +# embedded databases, evidence and state directory, and its own workflow-ID +# prefix on the shared native server, so profiles in different lanes cannot +# see each other. Within a lane they run in order. Every profile runs even +# after another fails; the script fails if any did, after printing each log. +# The job's sdk-direction-cleanup steps stop and remove every profile's +# containers afterwards, as before. +set -euo pipefail +backend="${1:?Choose mysql, pgsql or sqlite}" +native_url="${2:?Set the independently isolated native URL}" +test -n "${RESOURCE_SCOPE:?Set the owning isolated network/resource scope}" +mkdir -p parity-evidence/sdk-direction-lanes +# Profile variant and its tag (evidence, log and container names). The lanes are +# balanced by the durations recorded in CI (about seven to eight minutes each). +lanes=( + "update:sdk-updates update-failure:sdk-update-failures child:sdk-child-directions" + "namespace-signal:sdk-namespace-signals timer:sdk-timers activity:sdk-directions" + "selected-run-signal:sdk-selected-run-signals signal:sdk-signals query:sdk-queries" +) +run_lane() { + local entry variant tag status + for entry in $1; do + variant=${entry%%:*} tag=${entry#*:} status=0 + bash scripts/conformance/server-parity/sdk-direction-profile.sh "$backend" "$native_url" "$variant" \ + > "parity-evidence/sdk-direction-lanes/$tag.log" 2>&1 || status=$? + printf '%s\n' "$status" > "parity-evidence/sdk-direction-lanes/$tag.status" + done +} +for lane in "${lanes[@]}"; do + run_lane "$lane" & +done +wait +failed=0 +for lane in "${lanes[@]}"; do + for entry in $lane; do + tag=${entry#*:} + status=$(cat "parity-evidence/sdk-direction-lanes/$tag.status" 2>/dev/null || echo missing) + echo "::group::$tag (exit $status)" + cat "parity-evidence/sdk-direction-lanes/$tag.log" + echo "::endgroup::" + if [ "$status" != 0 ]; then + echo "::error::SDK direction profile $tag exited $status" + failed=1 + fi + done +done +exit "$failed" diff --git a/scripts/conformance/server-parity/sdk-directions/README.md b/scripts/conformance/server-parity/sdk-directions/README.md index f5fb86a88..5fea6d9ea 100644 --- a/scripts/conformance/server-parity/sdk-directions/README.md +++ b/scripts/conformance/server-parity/sdk-directions/README.md @@ -34,6 +34,8 @@ bash scripts/conformance/server-parity/sdk-direction-profile.sh BACKEND NATIVE_U bash scripts/conformance/server-parity/sdk-direction-cleanup.sh BACKEND ``` +CI runs every variant through `sdk-direction-lanes.sh BACKEND NATIVE_URL`, in three concurrent lanes against one native server; each variant keeps its own databases, containers and evidence. + Set `RESOURCE_SCOPE` to the owning network and supply `MYSQL_IMAGE` or `POSTGRES_IMAGE` for that backend, as the shared workflow does. The profile starts the exact PHP reference and embedded engine in independent databases; From bf73ac29f83baf2098e45ad5d6f4af4cbee6b93b Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:26:25 +0000 Subject: [PATCH 07/17] Compare activity lease renewals as instants, not RFC3339 text heartbeat_renews_only_progress_deadline_and_cannot_resurrect_elapsed_authority compared two lease_expires_at strings. Native times are whole milliseconds and serialize without a fraction on a whole second, so a claim lease of ...:26Z sorted after a renewal of ...:26.045Z and the assertion failed about one run in a thousand (train #729, PostgreSQL 17 job 120852521096). The test now parses both and reports the values. --- rust/tests/execution/activity_deadlines.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/rust/tests/execution/activity_deadlines.rs b/rust/tests/execution/activity_deadlines.rs index ea2d4d3a9..c67b5f724 100644 --- a/rust/tests/execution/activity_deadlines.rs +++ b/rust/tests/execution/activity_deadlines.rs @@ -483,9 +483,14 @@ async fn heartbeat_renews_only_progress_deadline_and_cannot_resurrect_elapsed_au assert_eq!(after.0, initial.0, "Start-to-close deadline remains fixed"); assert!(after.1 > initial.1, "Heartbeat deadline advances"); assert_eq!(after.2, initial.2, "Lifecycle deadline remains fixed"); + // Compare instants, not text: a lease on a whole second is written + // without a fraction ("...:26Z"), which sorts after "...:26.045Z". + let instant = |value: &Value| chrono::DateTime::parse_from_rfc3339(value.as_str().unwrap()).unwrap(); assert!( - renewed.1["lease_expires_at"].as_str().unwrap() - > activity["lease_expires_at"].as_str().unwrap() + instant(&renewed.1["lease_expires_at"]) > instant(&activity["lease_expires_at"]), + "{} renews {}", + renewed.1["lease_expires_at"], + activity["lease_expires_at"] ); let prefix = run_history(&app, &started["workflow_id"], &started["run_id"]).await; database From c04e9c6bf8542d97c9cd44f558d2ba8f37a01318 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:35:39 +0000 Subject: [PATCH 08/17] Report one Shared Server fixtures result on every pull request A required check and the merge queue both need a result that always reports. The pull_request path filter meant a docs-only pull request got no Shared Server fixtures result at all, so the workflow could not be required. - Pull requests now run the workflow without a path filter. A short `changes` job applies the push trigger's path list to the pull request's merge commit (renames count on both sides), so one list governs both triggers. Push, merge_group and manual runs run every fixture job, as does any pull request that cannot be classified. - `published-sdk-adapters` runs only when the classifier says so; the other fixture jobs need it, so they skip with it. - A terminal `Shared Server fixtures result` job runs with always(). It passes only when every fixture job passed, or when no input changed and every fixture job was skipped. A failed, cancelled or unexpectedly skipped job, a failed classifier, or an unreadable needs context fails it. - ServerParityAggregateTest covers the workflow structure, the result decisions and the classifier against scratch pull request merge commits. Repository contracts runs it. No permissions, secrets, actions or artifact paths change. Making the result a required check, and enabling the merge queue, stay separate repository-setting steps. --- .github/workflows/phpunit-feature.yml | 3 + .github/workflows/server-parity.yml | 103 ++++----- .../conformance/server-parity/aggregate.mjs | 51 +++++ scripts/conformance/server-parity/changes.mjs | 76 +++++++ tests/Unit/MergeGroupTriggersTest.mjs | 6 +- tests/Unit/ServerParityAggregateTest.mjs | 214 ++++++++++++++++++ 6 files changed, 396 insertions(+), 57 deletions(-) create mode 100644 scripts/conformance/server-parity/aggregate.mjs create mode 100644 scripts/conformance/server-parity/changes.mjs create mode 100644 tests/Unit/ServerParityAggregateTest.mjs diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 1c7809356..080304707 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -75,6 +75,9 @@ jobs: - name: Run the required checks for merge queue groups run: node --test tests/Unit/MergeGroupTriggersTest.mjs + - name: Report one Shared Server fixtures result on every pull request + run: node --test tests/Unit/ServerParityAggregateTest.mjs + - name: Validate embedded Workflow package authority run: php scripts/ci/test-workflow-package-authority.php diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index bef4bc698..e17e45fc5 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -1,61 +1,9 @@ name: Shared Server fixtures on: + # Pull requests run without a path filter so that the terminal result always + # reports. The `changes` job applies the push path list below to them. pull_request: - paths: - - .github/workflows/server-parity.yml - - scripts/conformance/server-parity* - - scripts/conformance/server-parity/** - - tests/Fixtures/ServerParity/** - - tests/Fixtures/ServerParityBaselines/** - - tests/Fixtures/ServerParityNormalization/** - - tests/Unit/ServerParityRunnerTest.mjs - - tests/Unit/ServerParityArtifactSummaryTest.mjs - - tests/Unit/ServerParityQueryObserverTest.mjs - - tests/Unit/ServerParityCodecDeadlineLoadTest.mjs - - tests/Unit/ServerParityCooperativeContractTest.mjs - - tests/Unit/ServerParityChildCancellationContractTest.mjs - - tests/Support/ServerParityChildCancellationObservation.mjs - - tests/Unit/ServerParityScheduleContractTest.mjs - - tests/Support/ServerParityScheduleObservation.mjs - - tests/Fixtures/ServerParityPending/** - - tests/Fixtures/ServerParityExpected/** - - tests/Fixtures/ServerParityProfiles/** - - tests/Unit/ServerParityVisibilityContractTest.mjs - - tests/Unit/ServerParityAdmissionContractTest.mjs - - tests/Unit/ServerParityRoleTokenContractTest.mjs - - tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs - - tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs - - tests/Unit/ServerParitySearchAttributeContractTest.mjs - - tests/Unit/ServerParityStorageTestContractTest.mjs - - tests/Unit/ServerParitySchedulePoliciesContractTest.mjs - - tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs - - tests/Unit/ServerParityScheduleCatalogContractTest.mjs - - tests/Unit/ServerParityWorkerListingContractTest.mjs - - tests/Unit/ServerParityWorkerReplacementContractTest.mjs - - tests/Unit/ServerParityOperatorMetricsContractTest.mjs - - tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs - - tests/Unit/ServerParityServiceCatalogContractTest.mjs - - tests/Unit/ServerParitySystemMetricsContractTest.mjs - - tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs - - tests/Unit/ServerParityPrometheusMetricsContractTest.mjs - - tests/Unit/ServerParityPayloadCleanupContractTest.mjs - - tests/Unit/ServerParityRepairPassContractTest.mjs - - tests/Unit/ServerParityClusterInfoContractTest.mjs - - tests/Unit/ServerParityNamespaceDeleteContractTest.mjs - - tests/Unit/ServerParityWaitingHistoryContractTest.mjs - - tests/Unit/ServerParityTaskRetryContractTest.mjs - - tests/Unit/ServerParityPublishedPatchContractTest.mjs - - tests/Unit/ServerParityPublishedPatchTransportTest.mjs - - tests/Unit/ServerParityLegacyMarkerContractTest.mjs - - tests/Unit/ServerParityRangeRepairContractTest.mjs - - tests/Unit/ServerParitySdkDirectionContractTest.mjs - - tests/Unit/ServerParitySdkChildDirectionContractTest.mjs - - tests/Unit/ServerParitySdkTimerAuthorContractTest.mjs - - tests/Unit/ServerParitySdkSignalDirectionContractTest.mjs - - tests/Support/ServerParityAdmissionObservation.mjs - - tests/Support/ServerParityVisibilityObservation.mjs - - rust/** push: branches: [main] paths: @@ -132,7 +80,27 @@ env: DW_PARITY_CONTINUE_MEMO_UPSERTS: '1' jobs: + changes: + name: Shared Server fixture inputs + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.classify.outputs.run }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 2 + persist-credentials: false + - name: Decide whether fixture inputs changed + id: classify + env: + EVENT_NAME: ${{ github.event_name }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/conformance/server-parity/changes.mjs >> "$GITHUB_OUTPUT" + published-sdk-adapters: + needs: changes + if: ${{ needs.changes.outputs.run == 'true' }} runs-on: ubuntu-latest timeout-minutes: 25 env: @@ -1881,3 +1849,30 @@ jobs: path: parity-summary retention-days: 1 if-no-files-found: error + + # The one Shared Server fixtures result. It always runs, including after a + # failure or cancellation, and passes only when every fixture job passed, or + # when no fixture input changed and every fixture job was skipped for that + # reason. + result: + name: Shared Server fixtures result + needs: + - changes + - published-sdk-adapters + - codec-deadline-health + - mysql-execution + - postgres-storage + - execution-and-codec + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + sparse-checkout: scripts/conformance/server-parity/aggregate.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Require every fixture job to pass, or every one to be skipped for unchanged inputs + env: + NEEDS: ${{ toJSON(needs) }} + run: node scripts/conformance/server-parity/aggregate.mjs diff --git a/scripts/conformance/server-parity/aggregate.mjs b/scripts/conformance/server-parity/aggregate.mjs new file mode 100644 index 000000000..6ee2481e7 --- /dev/null +++ b/scripts/conformance/server-parity/aggregate.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// The Shared Server fixtures result. NEEDS is the result job's `needs` +// context as JSON. +// +// Passes only when every fixture job passed, or when the classifier reported +// that no fixture input changed and every fixture job was skipped for that +// reason. A failed, cancelled or unexpectedly skipped job fails the result, +// and so does anything that cannot be read. +import {pathToFileURL} from 'node:url'; + +export function decide(needs) { + if (needs === null || typeof needs !== 'object' || Array.isArray(needs)) { + return {pass: false, reason: 'The needs context is not an object.'}; + } + const {changes, ...fixtureJobs} = needs; + const results = Object.entries(fixtureJobs).map(([job, value]) => [job, value?.result]); + const summary = results.map(([job, result]) => `${job}=${result}`).join(', '); + if (changes?.result !== 'success') { + return {pass: false, reason: `The change classifier did not succeed (${changes?.result}).`}; + } + if (results.length === 0) { + return {pass: false, reason: 'No fixture jobs reported.'}; + } + const run = changes.outputs?.run; + const expected = {true: 'success', false: 'skipped'}[run]; + if (expected === undefined) { + return {pass: false, reason: `The change classifier reported no decision (${run}).`}; + } + const unexpected = results.filter(([, result]) => result !== expected); + if (unexpected.length > 0) { + return { + pass: false, + reason: `Every fixture job must be ${expected} when run=${run}: ${unexpected.map(([job, result]) => `${job}=${result}`).join(', ')}.`, + }; + } + return { + pass: true, + reason: run === 'true' ? `Every fixture job passed: ${summary}.` : `No fixture input changed; every fixture job was skipped: ${summary}.`, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + let decision; + try { + decision = decide(JSON.parse(process.env.NEEDS ?? '')); + } catch (error) { + decision = {pass: false, reason: `The needs context could not be read: ${error.message}`}; + } + (decision.pass ? console.log : console.error)(decision.reason); + process.exitCode = decision.pass ? 0 : 1; +} diff --git a/scripts/conformance/server-parity/changes.mjs b/scripts/conformance/server-parity/changes.mjs new file mode 100644 index 000000000..2db3f34e8 --- /dev/null +++ b/scripts/conformance/server-parity/changes.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Decides whether Shared Server fixtures must run for this event. +// +// Pull requests run the workflow without a path filter, so its terminal +// result always reports. This script applies the workflow's own push path +// filter to the pull request's changes, so one list governs both triggers. +// Every other event runs every fixture job, and so does any pull request +// whose changes cannot be classified. +// +// Writes `run=true` or `run=false` to stdout, for $GITHUB_OUTPUT. +import {execFileSync} from 'node:child_process'; +import {readFileSync} from 'node:fs'; +import {pathToFileURL} from 'node:url'; + +// The `paths` list of the workflow's `on.push` trigger. +export function pushPathPatterns(workflowText) { + const lines = workflowText.split('\n'); + const on = lines.indexOf('on:'); + const push = lines.indexOf(' push:', on); + if (on < 0 || push < 0) throw new Error('The workflow has no on.push trigger'); + const patterns = []; + let inPaths = false; + for (const line of lines.slice(push + 1)) { + if (/^ {0,3}\S/.test(line)) break; + if (line === ' paths:') { + inPaths = true; + } else if (inPaths && line.startsWith(' - ')) { + patterns.push(line.slice(' - '.length).trim()); + } else if (inPaths) { + break; + } + } + if (patterns.length === 0) throw new Error('The push trigger has no path filter'); + return patterns; +} + +// GitHub's filter syntax, limited to the forms this workflow uses: +// `**` matches any characters, `*` matches any characters except `/`. +export function patternRegExp(pattern) { + if (!/^[A-Za-z0-9._/*-]+$/.test(pattern)) throw new Error(`Unsupported path filter pattern: ${pattern}`); + const body = pattern.split('**').map(part => part.split('*').map(text => text.replace(/[.]/g, '\\.')).join('[^/]*')).join('.*'); + return new RegExp(`^${body}$`); +} + +export function matchingFiles(patterns, files) { + const expressions = patterns.map(patternRegExp); + return files.filter(file => expressions.some(expression => expression.test(file))); +} + +function classify({event, headSha, workflowPath}) { + if (event !== 'pull_request') return {run: true, reason: `${event} runs every fixture job`}; + // The checkout is the pull request's merge commit: base, then head. + const git = (...args) => execFileSync('git', args, {encoding: 'utf8'}).trim(); + let files; + try { + const parents = git('rev-list', '--parents', '-n', '1', 'HEAD').split(' '); + if (parents.length !== 3 || parents[2] !== headSha) throw new Error('HEAD is not this pull request\'s merge commit'); + files = git('diff', '--name-only', '--no-renames', 'HEAD^1', 'HEAD').split('\n').filter(Boolean); + } catch (error) { + return {run: true, reason: `changes could not be classified (${error.message}); running every fixture job`}; + } + const matched = matchingFiles(pushPathPatterns(readFileSync(workflowPath, 'utf8')), files); + return matched.length > 0 + ? {run: true, reason: `fixture inputs changed: ${matched.join(', ')}`} + : {run: false, reason: `no fixture input among ${files.length} changed files`}; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const decision = classify({ + event: process.env.EVENT_NAME ?? '', + headSha: process.env.PR_HEAD_SHA ?? '', + workflowPath: '.github/workflows/server-parity.yml', + }); + console.error(decision.reason); + console.log(`run=${decision.run}`); +} diff --git a/tests/Unit/MergeGroupTriggersTest.mjs b/tests/Unit/MergeGroupTriggersTest.mjs index b4897d693..0b196a7df 100644 --- a/tests/Unit/MergeGroupTriggersTest.mjs +++ b/tests/Unit/MergeGroupTriggersTest.mjs @@ -1,7 +1,7 @@ // The Actions checks a GitHub merge queue waits for must run on merge_group. -// Source-only: enabling the queue is a separate repository setting, and its -// Sonar gate and terminal Shared Server fixtures result are separate -// prerequisites (Server #725). +// Source-only: enabling the queue is a separate repository setting. Its Sonar +// gate is a separate prerequisite (Server #725); the terminal Shared Server +// fixtures result is covered by ServerParityAggregateTest.mjs. import test from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; diff --git a/tests/Unit/ServerParityAggregateTest.mjs b/tests/Unit/ServerParityAggregateTest.mjs new file mode 100644 index 000000000..768e699fe --- /dev/null +++ b/tests/Unit/ServerParityAggregateTest.mjs @@ -0,0 +1,214 @@ +// The Shared Server fixtures workflow reports one terminal result on every +// pull request, so that result can become a required check (Server #725). +// Pull requests run without a path filter; the `changes` job applies the push +// path list to them, and the `result` job fails closed on anything other than +// all fixture jobs passing, or all of them skipped for unchanged inputs. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {execFileSync, spawnSync} from 'node:child_process'; +import {copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {matchingFiles, patternRegExp, pushPathPatterns} from '../../scripts/conformance/server-parity/changes.mjs'; +import {decide} from '../../scripts/conformance/server-parity/aggregate.mjs'; + +const workflowPath = '.github/workflows/server-parity.yml'; +const workflowText = readFileSync(workflowPath, 'utf8'); +const classifier = resolve('scripts/conformance/server-parity/changes.mjs'); +const aggregate = resolve('scripts/conformance/server-parity/aggregate.mjs'); + +// Top-level job ids and each job's block of text. +function jobs(text) { + const body = text.slice(text.indexOf('\njobs:\n') + '\njobs:\n'.length); + const blocks = {}; + let current; + for (const line of body.split('\n')) { + const header = /^ {2}([a-z0-9-]+):$/.exec(line); + if (header) { + current = header[1]; + blocks[current] = ''; + } else if (current) { + blocks[current] += `${line}\n`; + } + } + return blocks; +} + +function triggers(text) { + const start = text.indexOf('\non:\n') + '\non:\n'.length; + const rest = text.slice(start); + return rest.slice(0, rest.search(/\n[a-z_-]+:/)); +} + +test('pull requests run without a path filter and push keeps the one path list', () => { + const on = triggers(workflowText); + const pullRequest = on.slice(on.indexOf(' pull_request:\n'), on.indexOf(' push:\n')); + assert.doesNotMatch(pullRequest, /paths|branches/); + const patterns = pushPathPatterns(workflowText); + assert.ok(patterns.includes('rust/**')); + assert.ok(patterns.includes('.github/workflows/server-parity.yml')); + assert.ok(patterns.includes('scripts/conformance/server-parity/**')); +}); + +test('path patterns follow GitHub filter semantics for the forms in use', () => { + assert.ok(patternRegExp('rust/**').test('rust/src/main.rs')); + assert.ok(patternRegExp('scripts/conformance/server-parity*').test('scripts/conformance/server-parity.sh')); + assert.ok(!patternRegExp('scripts/conformance/server-parity*').test('scripts/conformance/server-parity/run.mjs')); + assert.ok(!patternRegExp('tests/Unit/ServerParityRunnerTest.mjs').test('tests/Unit/ServerParityRunnerTestXmjs')); + assert.throws(() => patternRegExp('docs/{a,b}/**'), /Unsupported path filter pattern/); + assert.deepEqual(matchingFiles(['rust/**', 'docs/x.md'], ['README.md', 'rust/Cargo.toml', 'docs/x.md']), ['rust/Cargo.toml', 'docs/x.md']); +}); + +test('the fixture jobs run only through the classifier and the result waits for all of them', () => { + const blocks = jobs(workflowText); + const ids = Object.keys(blocks); + assert.equal(ids[0], 'changes'); + assert.match(blocks.changes, /^ {4}timeout-minutes: \d+$/m); + assert.match(blocks.changes, /run: \$\{\{ steps\.classify\.outputs\.run \}\}/); + assert.match(blocks['published-sdk-adapters'], /^ {4}needs: changes\n {4}if: \$\{\{ needs\.changes\.outputs\.run == 'true' \}\}$/m); + const fixtureJobs = ids.filter(id => id !== 'changes' && id !== 'result'); + for (const id of fixtureJobs.filter(id => id !== 'published-sdk-adapters')) { + // A skipped published-sdk-adapters skips these jobs; no job-level + // condition may override that. + assert.match(blocks[id], /^ {4}needs: published-sdk-adapters$/m, id); + assert.doesNotMatch(blocks[id], /^ {4}if:/m, id); + } + const result = blocks.result; + assert.equal(ids.at(-1), 'result'); + assert.match(result, /^ {4}name: Shared Server fixtures result$/m); + assert.doesNotMatch(result, /strategy:/); + assert.match(result, /^ {4}if: \$\{\{ always\(\) \}\}$/m); + const needs = [...result.matchAll(/^ {6}- ([a-z0-9-]+)$/gm)].map(match => match[1]); + assert.deepEqual(needs.sort(), ['changes', ...fixtureJobs].sort()); + assert.match(result, /NEEDS: \$\{\{ toJSON\(needs\) \}\}/); + assert.match(result, /run: node scripts\/conformance\/server-parity\/aggregate\.mjs/); +}); + +function runAggregate(needs) { + const text = typeof needs === 'string' ? needs : JSON.stringify(needs); + return spawnSync('node', [aggregate], {env: {...process.env, NEEDS: text}, encoding: 'utf8'}); +} + +const fixture = (result, run) => ({ + changes: {result: 'success', outputs: run === undefined ? {} : {run}}, + 'published-sdk-adapters': {result, outputs: {}}, + 'codec-deadline-health': {result, outputs: {}}, + 'mysql-execution': {result, outputs: {}}, + 'postgres-storage': {result, outputs: {}}, + 'execution-and-codec': {result, outputs: {}}, +}); + +test('the result passes when every fixture job passed', () => { + const outcome = runAggregate(fixture('success', 'true')); + assert.equal(outcome.status, 0, outcome.stderr); + assert.match(outcome.stdout, /Every fixture job passed/); +}); + +test('the result passes when no input changed and every fixture job was skipped', () => { + const outcome = runAggregate(fixture('skipped', 'false')); + assert.equal(outcome.status, 0, outcome.stderr); + assert.match(outcome.stdout, /every fixture job was skipped/); +}); + +for (const [label, needs, reason] of [ + ['a failed fixture job', {...fixture('success', 'true'), 'mysql-execution': {result: 'failure', outputs: {}}}, /must be success when run=true: mysql-execution=failure\./], + ['a cancelled fixture job', {...fixture('success', 'true'), 'execution-and-codec': {result: 'cancelled', outputs: {}}}, /must be success when run=true: execution-and-codec=cancelled\./], + ['a skipped fixture job when inputs changed', {...fixture('success', 'true'), 'postgres-storage': {result: 'skipped', outputs: {}}}, /must be success when run=true: postgres-storage=skipped\./], + ['a fixture job that ran when no input changed', {...fixture('skipped', 'false'), 'codec-deadline-health': {result: 'success', outputs: {}}}, /must be skipped when run=false: codec-deadline-health=success\./], + ['a failed classifier', {...fixture('skipped', 'false'), changes: {result: 'failure', outputs: {}}}, /classifier did not succeed \(failure\)/], + ['a cancelled classifier', {...fixture('skipped'), changes: {result: 'cancelled', outputs: {}}}, /classifier did not succeed \(cancelled\)/], + ['a missing classifier', (({changes, ...rest}) => rest)(fixture('success', 'true')), /classifier did not succeed \(undefined\)/], + ['a classifier with no decision', fixture('skipped'), /reported no decision \(undefined\)/], + ['a classifier with an unknown decision', fixture('skipped', 'maybe'), /reported no decision \(maybe\)/], + ['no fixture jobs', {changes: {result: 'success', outputs: {run: 'true'}}}, /No fixture jobs reported/], + ['a context that is not an object', [], /not an object/], +]) { + test(`the result fails on ${label}`, () => { + const decision = decide(needs); + assert.equal(decision.pass, false); + assert.match(decision.reason, reason); + const outcome = runAggregate(needs); + assert.equal(outcome.status, 1); + assert.match(outcome.stderr, reason); + }); +} + +test('the result fails when the needs context cannot be read', () => { + for (const text of ['', 'not json']) { + const outcome = runAggregate(text); + assert.equal(outcome.status, 1); + assert.match(outcome.stderr, /could not be read/); + } +}); + +// A pull request merge commit in a scratch repository with this workflow. +function pullRequest(change) { + const root = mkdtempSync(join(tmpdir(), 'parity-changes-')); + const git = (...args) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', ...args], {cwd: root, encoding: 'utf8'}).trim(); + const write = (path, text) => { + mkdirSync(dirname(join(root, path)), {recursive: true}); + writeFileSync(join(root, path), text); + }; + git('init', '-q', '-b', 'main'); + mkdirSync(join(root, '.github/workflows'), {recursive: true}); + copyFileSync(workflowPath, join(root, workflowPath)); + write('README.md', 'base\n'); + write('tests/Fixtures/ServerParity/case.json', '{}\n'); + git('add', '-A'); + git('commit', '-q', '-m', 'base'); + git('checkout', '-q', '-b', 'head'); + change({root, write}); + git('add', '-A'); + git('commit', '-q', '-m', 'head'); + const head = git('rev-parse', 'HEAD'); + git('checkout', '-q', 'main'); + write('MAIN.md', 'main moved\n'); + git('add', '-A'); + git('commit', '-q', '-m', 'main'); + git('merge', '-q', '--no-ff', '-m', 'merge', 'head'); + return {root, head}; +} + +function classify(root, env) { + const outcome = spawnSync('node', [classifier], {cwd: root, env: {...process.env, ...env}, encoding: 'utf8'}); + assert.equal(outcome.status, 0, outcome.stderr); + return outcome.stdout.trim(); +} + +for (const [label, change, expected] of [ + ['a documentation-only change skips the fixtures', ({write}) => write('docs/note.md', 'x\n'), 'run=false'], + ['a Rust change runs the fixtures', ({write}) => write('rust/src/lib.rs', 'x\n'), 'run=true'], + ['moving a fixture out of the corpus runs the fixtures', ({root, write}) => { + write('docs/.keep', ''); + renameSync(join(root, 'tests/Fixtures/ServerParity/case.json'), join(root, 'docs/case.json')); + }, 'run=true'], +]) { + test(`pull request classification: ${label}`, () => { + const {root, head} = pullRequest(change); + try { + assert.equal(classify(root, {EVENT_NAME: 'pull_request', PR_HEAD_SHA: head}), expected); + } finally { + rmSync(root, {recursive: true, force: true}); + } + }); +} + +test('pull request classification runs every fixture when HEAD is not that pull request', () => { + const {root} = pullRequest(({write}) => write('docs/note.md', 'x\n')); + try { + assert.equal(classify(root, {EVENT_NAME: 'pull_request', PR_HEAD_SHA: '0'.repeat(40)}), 'run=true'); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +for (const event of ['push', 'merge_group', 'workflow_dispatch', '']) { + test(`${event || 'an unnamed event'} runs every fixture job`, () => { + const {root, head} = pullRequest(({write}) => write('docs/note.md', 'x\n')); + try { + assert.equal(classify(root, {EVENT_NAME: event, PR_HEAD_SHA: head}), 'run=true'); + } finally { + rmSync(root, {recursive: true, force: true}); + } + }); +} From 09450da71ef5a9211a7ec4ac57a57812f0f77e48 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:42:11 +0000 Subject: [PATCH 09/17] Run the fixtures classifier and result through shell helpers The Docker workflow isolation policy accepts only literal .sh and .py repository helpers in workflow steps, so the new jobs' direct node calls failed Public Boundary. changes.sh and aggregate.sh now run the Node scripts from the repository root, as the workflow does, and the classifier tests go through changes.sh. --- .github/workflows/server-parity.yml | 8 +++++--- .../conformance/server-parity/aggregate.sh | 5 +++++ scripts/conformance/server-parity/changes.sh | 5 +++++ tests/Unit/ServerParityAggregateTest.mjs | 20 ++++++++++++++++--- 4 files changed, 32 insertions(+), 6 deletions(-) create mode 100755 scripts/conformance/server-parity/aggregate.sh create mode 100755 scripts/conformance/server-parity/changes.sh diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index e17e45fc5..cbfa95064 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -96,7 +96,7 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: node scripts/conformance/server-parity/changes.mjs >> "$GITHUB_OUTPUT" + run: bash scripts/conformance/server-parity/changes.sh >> "$GITHUB_OUTPUT" published-sdk-adapters: needs: changes @@ -1869,10 +1869,12 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - sparse-checkout: scripts/conformance/server-parity/aggregate.mjs + sparse-checkout: | + scripts/conformance/server-parity/aggregate.sh + scripts/conformance/server-parity/aggregate.mjs sparse-checkout-cone-mode: false persist-credentials: false - name: Require every fixture job to pass, or every one to be skipped for unchanged inputs env: NEEDS: ${{ toJSON(needs) }} - run: node scripts/conformance/server-parity/aggregate.mjs + run: bash scripts/conformance/server-parity/aggregate.sh diff --git a/scripts/conformance/server-parity/aggregate.sh b/scripts/conformance/server-parity/aggregate.sh new file mode 100755 index 000000000..013908c66 --- /dev/null +++ b/scripts/conformance/server-parity/aggregate.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# The Shared Server fixtures result, from the repository root. NEEDS is the +# result job's needs context as JSON. The decision rules live in aggregate.mjs. +set -euo pipefail +exec node scripts/conformance/server-parity/aggregate.mjs diff --git a/scripts/conformance/server-parity/changes.sh b/scripts/conformance/server-parity/changes.sh new file mode 100755 index 000000000..4f8635f0a --- /dev/null +++ b/scripts/conformance/server-parity/changes.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Prints run=true or run=false for the Shared Server fixtures workflow, from +# the repository root. The decision rules live in changes.mjs. +set -euo pipefail +exec node scripts/conformance/server-parity/changes.mjs diff --git a/tests/Unit/ServerParityAggregateTest.mjs b/tests/Unit/ServerParityAggregateTest.mjs index 768e699fe..0eab0f4df 100644 --- a/tests/Unit/ServerParityAggregateTest.mjs +++ b/tests/Unit/ServerParityAggregateTest.mjs @@ -14,8 +14,9 @@ import {decide} from '../../scripts/conformance/server-parity/aggregate.mjs'; const workflowPath = '.github/workflows/server-parity.yml'; const workflowText = readFileSync(workflowPath, 'utf8'); -const classifier = resolve('scripts/conformance/server-parity/changes.mjs'); const aggregate = resolve('scripts/conformance/server-parity/aggregate.mjs'); +const aggregateWrapper = 'scripts/conformance/server-parity/aggregate.sh'; +const classifierWrapper = 'scripts/conformance/server-parity/changes.sh'; // Top-level job ids and each job's block of text. function jobs(text) { @@ -65,6 +66,7 @@ test('the fixture jobs run only through the classifier and the result waits for assert.equal(ids[0], 'changes'); assert.match(blocks.changes, /^ {4}timeout-minutes: \d+$/m); assert.match(blocks.changes, /run: \$\{\{ steps\.classify\.outputs\.run \}\}/); + assert.match(blocks.changes, /run: bash scripts\/conformance\/server-parity\/changes\.sh >> "\$GITHUB_OUTPUT"$/m); assert.match(blocks['published-sdk-adapters'], /^ {4}needs: changes\n {4}if: \$\{\{ needs\.changes\.outputs\.run == 'true' \}\}$/m); const fixtureJobs = ids.filter(id => id !== 'changes' && id !== 'result'); for (const id of fixtureJobs.filter(id => id !== 'published-sdk-adapters')) { @@ -81,7 +83,8 @@ test('the fixture jobs run only through the classifier and the result waits for const needs = [...result.matchAll(/^ {6}- ([a-z0-9-]+)$/gm)].map(match => match[1]); assert.deepEqual(needs.sort(), ['changes', ...fixtureJobs].sort()); assert.match(result, /NEEDS: \$\{\{ toJSON\(needs\) \}\}/); - assert.match(result, /run: node scripts\/conformance\/server-parity\/aggregate\.mjs/); + assert.match(result, /run: bash scripts\/conformance\/server-parity\/aggregate\.sh$/m); + assert.match(result, /sparse-checkout: \|\n {12}scripts\/conformance\/server-parity\/aggregate\.sh\n {12}scripts\/conformance\/server-parity\/aggregate\.mjs\n/); }); function runAggregate(needs) { @@ -133,6 +136,14 @@ for (const [label, needs, reason] of [ }); } +test('the workflow wrapper passes the result through, from the repository root', () => { + const run = needs => spawnSync('bash', [aggregateWrapper], {env: {...process.env, NEEDS: JSON.stringify(needs)}, encoding: 'utf8'}); + assert.equal(run(fixture('success', 'true')).status, 0); + const failed = run({...fixture('success', 'true'), 'mysql-execution': {result: 'failure', outputs: {}}}); + assert.equal(failed.status, 1); + assert.match(failed.stderr, /mysql-execution=failure/); +}); + test('the result fails when the needs context cannot be read', () => { for (const text of ['', 'not json']) { const outcome = runAggregate(text); @@ -152,6 +163,8 @@ function pullRequest(change) { git('init', '-q', '-b', 'main'); mkdirSync(join(root, '.github/workflows'), {recursive: true}); copyFileSync(workflowPath, join(root, workflowPath)); + mkdirSync(join(root, dirname(classifierWrapper)), {recursive: true}); + for (const file of [classifierWrapper, 'scripts/conformance/server-parity/changes.mjs']) copyFileSync(file, join(root, file)); write('README.md', 'base\n'); write('tests/Fixtures/ServerParity/case.json', '{}\n'); git('add', '-A'); @@ -170,7 +183,8 @@ function pullRequest(change) { } function classify(root, env) { - const outcome = spawnSync('node', [classifier], {cwd: root, env: {...process.env, ...env}, encoding: 'utf8'}); + // The workflow runs the wrapper from the repository root. + const outcome = spawnSync('bash', [classifierWrapper], {cwd: root, env: {...process.env, ...env}, encoding: 'utf8'}); assert.equal(outcome.status, 0, outcome.stderr); return outcome.stdout.trim(); } From 337fe8de1658888cc9ab1bf133d22b16eb27e30f Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:41:38 +0000 Subject: [PATCH 10/17] CI: build the deadline-health native binary in its own job Every database job waited for published-sdk-adapters (about 15 minutes), but 10 of those minutes built the optimized native binary only the codec-deadline-health cells use (train #729: "Build one bounded optimized native artifact for deadline health", 10.0 minutes). The build, its upload and its container cleanup now run in a codec-native-build job beside the adapters; codec-deadline-health needs both, and the database jobs still need only the adapters, so they start about 10 minutes sooner. The build script and its bounded resources are unchanged. --- .github/workflows/server-parity.yml | 47 ++++++++++++++++++----------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 3384b7a4f..7b3b443ca 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -195,23 +195,6 @@ jobs: path: corrected-marker-adapters retention-days: 1 if-no-files-found: error - - name: Build one bounded optimized native artifact for deadline health - run: bash scripts/conformance/server-parity/codec-operations-build.sh codec-native-build - - name: Share the optimized binary without compiler caches - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: codec-native-build - path: | - codec-native-build/server - codec-native-build/build.json - codec-native-build/SHA256SUMS - retention-days: 1 - if-no-files-found: error - - name: Retire the owned optimized build container - if: always() - run: | - build_container="$RESOURCE_SCOPE-build" - docker rm -f "$build_container" >/dev/null 2>&1 || true - name: Share thin published SDK direction adapters uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -254,8 +237,36 @@ jobs: retention-days: 1 if-no-files-found: ignore + # The optimized native build only the deadline-health cells use. It runs + # beside the SDK adapters, so the database jobs, which need only the + # adapters, no longer wait for it. + codec-native-build: + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + RESOURCE_SCOPE: dw-codec-native-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Build one bounded optimized native artifact for deadline health + run: bash scripts/conformance/server-parity/codec-operations-build.sh codec-native-build + - name: Share the optimized binary without compiler caches + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: codec-native-build + path: | + codec-native-build/server + codec-native-build/build.json + codec-native-build/SHA256SUMS + retention-days: 1 + if-no-files-found: error + - name: Retire the owned optimized build container + if: always() + run: | + build_container="$RESOURCE_SCOPE-build" + docker rm -f "$build_container" >/dev/null 2>&1 || true + codec-deadline-health: - needs: published-sdk-adapters + needs: [published-sdk-adapters, codec-native-build] runs-on: ubuntu-latest timeout-minutes: 10 strategy: From f877ccc9cfbcb281f1dde2c5ed70ecdb9918482e Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:46:55 +0000 Subject: [PATCH 11/17] Escape every pattern metacharacter in path filters --- scripts/conformance/server-parity/changes.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/conformance/server-parity/changes.mjs b/scripts/conformance/server-parity/changes.mjs index 2db3f34e8..2398a3836 100644 --- a/scripts/conformance/server-parity/changes.mjs +++ b/scripts/conformance/server-parity/changes.mjs @@ -38,7 +38,7 @@ export function pushPathPatterns(workflowText) { // `**` matches any characters, `*` matches any characters except `/`. export function patternRegExp(pattern) { if (!/^[A-Za-z0-9._/*-]+$/.test(pattern)) throw new Error(`Unsupported path filter pattern: ${pattern}`); - const body = pattern.split('**').map(part => part.split('*').map(text => text.replace(/[.]/g, '\\.')).join('[^/]*')).join('.*'); + const body = pattern.split('**').map(part => part.split('*').map(text => text.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')).join('[^/]*')).join('.*'); return new RegExp(`^${body}$`); } From c834849c8b0e0de1f630f9090a0a842e95c0a8c5 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:01:57 +0000 Subject: [PATCH 12/17] Stop each SDK direction profile's PHP server after its attempt, and test the lane wrapper Each lane stops its profile's PHP server as soon as the profile ends, so at most three run at once, and a failed stop only warns. The profile's status is kept either way. A shell-level test runs the wrapper with stub profiles and docker: all nine profiles run, every server is stopped after its profile, a failed profile does not stop its lane, and the wrapper fails only after printing every status and log. Also resolves the wrapper's Sonar findings. --- .github/workflows/server-parity.yml | 4 +- .../server-parity/sdk-direction-lanes.sh | 37 +++-- .../ServerParitySdkDirectionLanesTest.mjs | 133 ++++++++++++++++++ 3 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 tests/Unit/ServerParitySdkDirectionLanesTest.mjs diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 7b3b443ca..0bcd5f5a7 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -10,6 +10,7 @@ on: - tests/Fixtures/ServerParityBaselines/** - tests/Fixtures/ServerParityNormalization/** - tests/Unit/ServerParityRunnerTest.mjs + - tests/Unit/ServerParitySdkDirectionLanesTest.mjs - tests/Unit/ServerParityArtifactSummaryTest.mjs - tests/Unit/ServerParityQueryObserverTest.mjs - tests/Unit/ServerParityCodecDeadlineLoadTest.mjs @@ -66,6 +67,7 @@ on: - tests/Fixtures/ServerParityBaselines/** - tests/Fixtures/ServerParityNormalization/** - tests/Unit/ServerParityRunnerTest.mjs + - tests/Unit/ServerParitySdkDirectionLanesTest.mjs - tests/Unit/ServerParityArtifactSummaryTest.mjs - tests/Unit/ServerParityQueryObserverTest.mjs - tests/Unit/ServerParityCodecDeadlineLoadTest.mjs @@ -1462,7 +1464,7 @@ jobs: -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \ - "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs > parity-evidence/comparator-tests.txt + "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParitySdkDirectionLanesTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs > parity-evidence/comparator-tests.txt docker run --rm --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ --network=none --entrypoint node -v "$PWD:/source:ro" -w /source \ "$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-tests.txt diff --git a/scripts/conformance/server-parity/sdk-direction-lanes.sh b/scripts/conformance/server-parity/sdk-direction-lanes.sh index 87f05cd88..59e3dfc80 100755 --- a/scripts/conformance/server-parity/sdk-direction-lanes.sh +++ b/scripts/conformance/server-parity/sdk-direction-lanes.sh @@ -3,15 +3,20 @@ # three concurrent lanes. Each profile already has its own containers, PHP and # embedded databases, evidence and state directory, and its own workflow-ID # prefix on the shared native server, so profiles in different lanes cannot -# see each other. Within a lane they run in order. Every profile runs even -# after another fails; the script fails if any did, after printing each log. -# The job's sdk-direction-cleanup steps stop and remove every profile's -# containers afterwards, as before. +# see each other. Within a lane they run in order, and each profile's PHP +# server is stopped as soon as its attempt ends, so at most three run at once. +# Every profile runs even after another fails; the script fails if any did, +# after printing each log. The job's sdk-direction-cleanup steps remove every +# profile's containers afterwards, as before. set -euo pipefail backend="${1:?Choose mysql, pgsql or sqlite}" native_url="${2:?Set the independently isolated native URL}" -test -n "${RESOURCE_SCOPE:?Set the owning isolated network/resource scope}" -mkdir -p parity-evidence/sdk-direction-lanes +if [[ -z "${RESOURCE_SCOPE:-}" ]]; then + echo 'Set the owning isolated network/resource scope' >&2 + exit 2 +fi +evidence=parity-evidence/sdk-direction-lanes +mkdir -p "$evidence" # Profile variant and its tag (evidence, log and container names). The lanes are # balanced by the durations recorded in CI (about seven to eight minutes each). lanes=( @@ -20,12 +25,16 @@ lanes=( "selected-run-signal:sdk-selected-run-signals signal:sdk-signals query:sdk-queries" ) run_lane() { - local entry variant tag status - for entry in $1; do + local lane=$1 entry variant tag status + for entry in $lane; do variant=${entry%%:*} tag=${entry#*:} status=0 bash scripts/conformance/server-parity/sdk-direction-profile.sh "$backend" "$native_url" "$variant" \ - > "parity-evidence/sdk-direction-lanes/$tag.log" 2>&1 || status=$? - printf '%s\n' "$status" > "parity-evidence/sdk-direction-lanes/$tag.status" + > "$evidence/$tag.log" 2>&1 || status=$? + printf '%s\n' "$status" > "$evidence/$tag.status" + # The profile's status is already kept; a failed stop leaves the server to + # the final cleanup. + docker stop --time 30 "$RESOURCE_SCOPE-$tag-server" >/dev/null 2>&1 \ + || echo "::warning::The $tag PHP server was not stopped; the final cleanup removes it" >&2 done } for lane in "${lanes[@]}"; do @@ -36,12 +45,12 @@ failed=0 for lane in "${lanes[@]}"; do for entry in $lane; do tag=${entry#*:} - status=$(cat "parity-evidence/sdk-direction-lanes/$tag.status" 2>/dev/null || echo missing) + status=$(cat "$evidence/$tag.status" 2>/dev/null || echo missing) echo "::group::$tag (exit $status)" - cat "parity-evidence/sdk-direction-lanes/$tag.log" + cat "$evidence/$tag.log" echo "::endgroup::" - if [ "$status" != 0 ]; then - echo "::error::SDK direction profile $tag exited $status" + if [[ "$status" != 0 ]]; then + echo "::error::SDK direction profile $tag exited $status" >&2 failed=1 fi done diff --git a/tests/Unit/ServerParitySdkDirectionLanesTest.mjs b/tests/Unit/ServerParitySdkDirectionLanesTest.mjs new file mode 100644 index 000000000..250603298 --- /dev/null +++ b/tests/Unit/ServerParitySdkDirectionLanesTest.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'node:test'; + +const LANES = new URL('../../scripts/conformance/server-parity/sdk-direction-lanes.sh', import.meta.url).pathname; +const PROFILES = { + update: 'sdk-updates', 'update-failure': 'sdk-update-failures', child: 'sdk-child-directions', + 'namespace-signal': 'sdk-namespace-signals', timer: 'sdk-timers', activity: 'sdk-directions', + 'selected-run-signal': 'sdk-selected-run-signals', signal: 'sdk-signals', query: 'sdk-queries', +}; + +// Runs the wrapper in a scratch tree whose profile script and `docker` are +// stubs that append to one event log: a profile starts its PHP server and +// exits FAIL_ (default 0); `docker stop` exits STOP_FAIL_. +function runLanes(environment = {}) { + const root = mkdtempSync(join(tmpdir(), 'dw-sdk-lanes-')); + try { + const scripts = join(root, 'scripts/conformance/server-parity'); + const bin = join(root, 'bin'); + mkdirSync(scripts, {recursive: true}); + mkdirSync(bin); + copyFileSync(LANES, join(scripts, 'sdk-direction-lanes.sh')); + const events = join(root, 'events.log'); + writeFileSync(join(scripts, 'sdk-direction-profile.sh'), `#!/usr/bin/env bash +set -euo pipefail +variant=$3 +echo "start $variant $1 $2" >> "$EVENTS" +echo "profile output for $variant" +sleep 0.2 +echo "end $variant" >> "$EVENTS" +fail="FAIL_\${variant//-/_}" +exit "\${!fail:-0}" +`); + writeFileSync(join(bin, 'docker'), `#!/usr/bin/env bash +echo "docker $*" >> "$EVENTS" +name=\${*: -1} +tag=\${name#"$RESOURCE_SCOPE"-} +tag=\${tag%-server} +fail="STOP_FAIL_\${tag//-/_}" +exit "\${!fail:-0}" +`); + chmodSync(join(bin, 'docker'), 0o755); + const child = spawnSync('bash', ['scripts/conformance/server-parity/sdk-direction-lanes.sh', 'sqlite', 'http://native:8080'], { + cwd: root, encoding: 'utf8', timeout: 30000, + env: {...process.env, ...environment, PATH: `${bin}:${process.env.PATH}`, EVENTS: events, RESOURCE_SCOPE: 'dw-scope-1'}, + }); + const statuses = Object.fromEntries(Object.values(PROFILES).map(tag => [tag, + readFileSync(join(root, 'parity-evidence/sdk-direction-lanes', `${tag}.status`), 'utf8').trim()])); + return {child, statuses, events: readFileSync(events, 'utf8').trim().split('\n')}; + } finally { + rmSync(root, {recursive: true, force: true}); + } +} + +const started = (events, variant) => events.findIndex(event => event.startsWith(`start ${variant} `)); + +// Each profile ran once against the given backend and URL, and its PHP server +// was stopped after the profile ended and before the lane's next profile. +function assertEveryServerRetired(events) { + const running = new Set(); + let peak = 0; + for (const event of events) { + const [kind, ...rest] = event.split(' '); + if (kind === 'start') { + assert.deepEqual(rest.slice(1), ['sqlite', 'http://native:8080'], event); + running.add(PROFILES[rest[0]]); + peak = Math.max(peak, running.size); + } else if (kind === 'docker') { + assert.deepEqual(rest.slice(0, 3), ['stop', '--time', '30'], event); + const tag = rest[3].replace(/^dw-scope-1-/, '').replace(/-server$/, ''); + assert.ok(running.delete(tag), `${tag} stopped once, after it started`); + const end = events.indexOf(`end ${Object.keys(PROFILES).find(variant => PROFILES[variant] === tag)}`); + assert.ok(end >= 0 && end < events.indexOf(event), `${tag} stopped after its profile ended`); + } + } + assert.equal(running.size, 0, `servers left running: ${[...running]}`); + assert.ok(peak <= 3, `at most three PHP servers run at once, not ${peak}`); + const started = events.filter(event => event.startsWith('start ')).map(event => event.split(' ')[1]).sort(); + assert.deepEqual(started, Object.keys(PROFILES).sort(), 'all nine profiles ran once'); + assert.deepEqual(events.filter(event => event.startsWith('docker ')).map(event => event.split(' ').at(-1)).sort(), + Object.values(PROFILES).map(tag => `dw-scope-1-${tag}-server`).sort(), 'every profile server stopped once'); +} + +test('all nine profiles pass and each PHP server is stopped after its profile', () => { + const {child, statuses, events} = runLanes(); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(new Set(Object.values(statuses)), new Set(['0'])); + assertEveryServerRetired(events); + for (const [variant, tag] of Object.entries(PROFILES)) { + assert.match(child.stdout, new RegExp(`::group::${tag} \\(exit 0\\)\\nprofile output for ${variant}\\n::endgroup::`)); + } + assert.doesNotMatch(child.stderr, /::error::|::warning::/); +}); + +test('a failed profile still runs its lane and fails the wrapper only after every status and log', () => { + // The timer profile is second in its lane; the activity profile follows it. + const {child, statuses, events} = runLanes({FAIL_timer: '7'}); + assert.equal(child.status, 1); + assert.deepEqual(statuses, {...Object.fromEntries(Object.values(PROFILES).map(tag => [tag, '0'])), 'sdk-timers': '7'}); + assertEveryServerRetired(events); + assert.ok(started(events, 'activity') > events.indexOf('end timer'), 'the lane continues after a failure'); + assert.match(child.stderr, /^::error::SDK direction profile sdk-timers exited 7$/m); + assert.equal(child.stderr.match(/::error::/g).length, 1); + assert.match(child.stdout, /::group::sdk-timers \(exit 7\)\nprofile output for timer\n::endgroup::/); + assert.match(child.stdout, /::group::sdk-queries \(exit 0\)/, 'logs after the failure are still printed'); +}); + +test('a failed stop warns without changing any profile status', () => { + const {child, statuses, events} = runLanes({STOP_FAIL_sdk_updates: '1'}); + assert.equal(child.status, 0, child.stderr); + assert.equal(statuses['sdk-updates'], '0'); + assert.match(child.stderr, /^::warning::The sdk-updates PHP server was not stopped; the final cleanup removes it$/m); + assert.ok(started(events, 'update-failure') > events.indexOf('docker stop --time 30 dw-scope-1-sdk-updates-server'), + 'the lane continues after a failed stop'); +}); + +test('a failed profile and a failed stop of its server keep the profile status', () => { + const {child, statuses} = runLanes({FAIL_query: '3', STOP_FAIL_sdk_queries: '1'}); + assert.equal(child.status, 1); + assert.equal(statuses['sdk-queries'], '3'); + assert.match(child.stderr, /^::error::SDK direction profile sdk-queries exited 3$/m); +}); + +test('the wrapper refuses to run without an owning resource scope', () => { + const child = spawnSync('bash', [LANES, 'sqlite', 'http://native:8080'], { + encoding: 'utf8', env: {...process.env, RESOURCE_SCOPE: ''}, cwd: tmpdir(), + }); + assert.equal(child.status, 2); + assert.match(child.stderr, /Set the owning isolated network\/resource scope/); +}); From 62b1ceecf39850b0e9310d1f411cdfabe10117ac Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:03:45 +0000 Subject: [PATCH 13/17] Classify pull request changes from NUL-separated paths, and run every fixture when patterns or names cannot be classified The file list is read as bytes with --name-only -z and split only on NUL, so a quoted name can no longer slip past a required prefix; wildcards match newlines; a name that is not UTF-8 or an unsupported pattern runs every fixture job instead of failing the classifier. --- scripts/conformance/server-parity/changes.mjs | 35 +++++++++++++---- tests/Unit/ServerParityAggregateTest.mjs | 39 ++++++++++++++++++- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/scripts/conformance/server-parity/changes.mjs b/scripts/conformance/server-parity/changes.mjs index 2398a3836..910b00d7e 100644 --- a/scripts/conformance/server-parity/changes.mjs +++ b/scripts/conformance/server-parity/changes.mjs @@ -35,13 +35,30 @@ export function pushPathPatterns(workflowText) { } // GitHub's filter syntax, limited to the forms this workflow uses: -// `**` matches any characters, `*` matches any characters except `/`. +// `**` matches any characters, `*` matches any characters except `/`. Every +// other character is literal, and file names may hold any character but NUL, +// newlines included. export function patternRegExp(pattern) { if (!/^[A-Za-z0-9._/*-]+$/.test(pattern)) throw new Error(`Unsupported path filter pattern: ${pattern}`); - const body = pattern.split('**').map(part => part.split('*').map(text => text.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')).join('[^/]*')).join('.*'); + const literal = text => text.replace(/[\\^$.*+?()[\]{}|\/-]/g, '\\$&'); + const body = pattern.split('**').map(part => part.split('*').map(literal).join('[^/]*')).join('[^]*'); return new RegExp(`^${body}$`); } +// `git diff --name-only -z` output: complete paths split only on NUL, never +// quoted. A path that is not UTF-8 cannot be classified. +export function nulSeparatedPaths(bytes) { + const decoder = new TextDecoder('utf-8', {fatal: true}); + const paths = []; + let start = 0; + for (let index = bytes.indexOf(0); index >= 0; index = bytes.indexOf(0, start)) { + paths.push(decoder.decode(bytes.subarray(start, index))); + start = index + 1; + } + if (start !== bytes.length) throw new Error('The file list does not end with NUL'); + return paths; +} + export function matchingFiles(patterns, files) { const expressions = patterns.map(patternRegExp); return files.filter(file => expressions.some(expression => expression.test(file))); @@ -49,19 +66,21 @@ export function matchingFiles(patterns, files) { function classify({event, headSha, workflowPath}) { if (event !== 'pull_request') return {run: true, reason: `${event} runs every fixture job`}; - // The checkout is the pull request's merge commit: base, then head. - const git = (...args) => execFileSync('git', args, {encoding: 'utf8'}).trim(); + // The checkout is the pull request's merge commit: base, then head. Git runs + // without a shell, and its file list is read as bytes. + const git = (...args) => execFileSync('git', args); let files; + let matched; try { - const parents = git('rev-list', '--parents', '-n', '1', 'HEAD').split(' '); + const parents = git('rev-list', '--parents', '-n', '1', 'HEAD').toString('utf8').trim().split(' '); if (parents.length !== 3 || parents[2] !== headSha) throw new Error('HEAD is not this pull request\'s merge commit'); - files = git('diff', '--name-only', '--no-renames', 'HEAD^1', 'HEAD').split('\n').filter(Boolean); + files = nulSeparatedPaths(git('diff', '--name-only', '-z', '--no-renames', 'HEAD^1', 'HEAD')); + matched = matchingFiles(pushPathPatterns(readFileSync(workflowPath, 'utf8')), files); } catch (error) { return {run: true, reason: `changes could not be classified (${error.message}); running every fixture job`}; } - const matched = matchingFiles(pushPathPatterns(readFileSync(workflowPath, 'utf8')), files); return matched.length > 0 - ? {run: true, reason: `fixture inputs changed: ${matched.join(', ')}`} + ? {run: true, reason: `fixture inputs changed: ${matched.map(file => JSON.stringify(file)).join(', ')}`} : {run: false, reason: `no fixture input among ${files.length} changed files`}; } diff --git a/tests/Unit/ServerParityAggregateTest.mjs b/tests/Unit/ServerParityAggregateTest.mjs index 0eab0f4df..d52ad3893 100644 --- a/tests/Unit/ServerParityAggregateTest.mjs +++ b/tests/Unit/ServerParityAggregateTest.mjs @@ -9,7 +9,7 @@ import {execFileSync, spawnSync} from 'node:child_process'; import {copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {dirname, join, resolve} from 'node:path'; -import {matchingFiles, patternRegExp, pushPathPatterns} from '../../scripts/conformance/server-parity/changes.mjs'; +import {matchingFiles, nulSeparatedPaths, patternRegExp, pushPathPatterns} from '../../scripts/conformance/server-parity/changes.mjs'; import {decide} from '../../scripts/conformance/server-parity/aggregate.mjs'; const workflowPath = '.github/workflows/server-parity.yml'; @@ -60,6 +60,31 @@ test('path patterns follow GitHub filter semantics for the forms in use', () => assert.deepEqual(matchingFiles(['rust/**', 'docs/x.md'], ['README.md', 'rust/Cargo.toml', 'docs/x.md']), ['rust/Cargo.toml', 'docs/x.md']); }); +test('path patterns accept no regular-expression syntax and match every other character literally', () => { + for (const pattern of ['rust\\**', 'rust/\\d', 'a+b', 'a?b', 'a(b)', 'a[b]', 'a|b', 'a^b', 'a$b', 'a{1}', 'docs/{a,b}/**', 'a b', '']) { + assert.throws(() => patternRegExp(pattern), /Unsupported path filter pattern/, JSON.stringify(pattern)); + } + assert.ok(!patternRegExp('a.b').test('aXb')); + assert.ok(patternRegExp('a-b/c_d.e').test('a-b/c_d.e')); + assert.ok(!patternRegExp('a-b').test('a+b')); +}); + +test('path patterns match file names holding newlines and tabs', () => { + assert.ok(patternRegExp('rust/**').test('rust/hidden\nfixture.rs')); + assert.ok(patternRegExp('rust/**').test('rust/a\n/b\tc.rs')); + assert.ok(patternRegExp('scripts/conformance/server-parity*').test('scripts/conformance/server-parity\n.sh')); + assert.ok(!patternRegExp('scripts/conformance/server-parity*').test('scripts/conformance/server-parity\n/run.mjs')); + assert.ok(!patternRegExp('rust/**').test('docs/x\nrust/y')); +}); + +test('the file list splits only on NUL and refuses what it cannot decode', () => { + assert.deepEqual(nulSeparatedPaths(Buffer.from('README.md\0rust/hidden\nfixture.rs\0 lead\ttab \0')), + ['README.md', 'rust/hidden\nfixture.rs', ' lead\ttab ']); + assert.deepEqual(nulSeparatedPaths(Buffer.alloc(0)), []); + assert.throws(() => nulSeparatedPaths(Buffer.from('rust/a.rs')), /does not end with NUL/); + assert.throws(() => nulSeparatedPaths(Buffer.from([0x72, 0xff, 0])), TypeError); +}); + test('the fixture jobs run only through the classifier and the result waits for all of them', () => { const blocks = jobs(workflowText); const ids = Object.keys(blocks); @@ -192,6 +217,18 @@ function classify(root, env) { for (const [label, change, expected] of [ ['a documentation-only change skips the fixtures', ({write}) => write('docs/note.md', 'x\n'), 'run=false'], ['a Rust change runs the fixtures', ({write}) => write('rust/src/lib.rs', 'x\n'), 'run=true'], + ['a Rust file whose name holds a newline runs the fixtures', ({write}) => write('rust/hidden\nfixture.rs', 'x\n'), 'run=true'], + ['a Rust file whose name holds a tab runs the fixtures', ({write}) => write('rust/hidden\tfixture.rs', 'x\n'), 'run=true'], + ['a fixture whose name holds a newline runs the fixtures', ({write}) => write('tests/Fixtures/ServerParity/a\nb.json', '{}\n'), 'run=true'], + ['a documentation file whose name holds a newline still skips', ({write}) => write('docs/a\nrust/b.md', 'x\n'), 'run=false'], + ['a file name that is not UTF-8 runs the fixtures', ({root}) => { + mkdirSync(join(root, 'docs'), {recursive: true}); + writeFileSync(Buffer.concat([Buffer.from(join(root, 'docs/')), Buffer.from([0xff, 0x2e, 0x6d, 0x64])]), 'x\n'); + }, 'run=true'], + ['an unsupported push path pattern runs the fixtures', ({write}) => { + write('docs/note.md', 'x\n'); + write(workflowPath, workflowText.replace(' paths:\n', ' paths:\n - docs/{a,b}/**\n')); + }, 'run=true'], ['moving a fixture out of the corpus runs the fixtures', ({root, write}) => { write('docs/.keep', ''); renameSync(join(root, 'tests/Fixtures/ServerParity/case.json'), join(root, 'docs/case.json')); From 12a1af02b2e77b85a2a9138f21dd63fe6e189fd5 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:14:26 +0000 Subject: [PATCH 14/17] CI: run each fixture database job in three parallel parts Each MySQL, PostgreSQL and SQLite job becomes three matrix parts on separate runners: native-tests (the native database suites, or the codec suite, and the TLS checks), corpus (the shared corpus, its profiles and the restart and takeover cases), and cohorts (role tokens, upgrade sources, corrected markers, external consumers, query authority, worker fencing and pressure). Every part bootstraps the same database and native build, and none depends on another; the cohorts start their own native node for the corrected markers. Every check still runs exactly once, which a structure test pins. --- .github/workflows/phpunit-feature.yml | 2 +- .github/workflows/server-parity.yml | 231 ++++++++++++++++++++------ tests/Unit/ServerParityPartsTest.mjs | 91 ++++++++++ 3 files changed, 272 insertions(+), 52 deletions(-) create mode 100644 tests/Unit/ServerParityPartsTest.mjs diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 080304707..8810c5c45 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -76,7 +76,7 @@ jobs: run: node --test tests/Unit/MergeGroupTriggersTest.mjs - name: Report one Shared Server fixtures result on every pull request - run: node --test tests/Unit/ServerParityAggregateTest.mjs + run: node --test tests/Unit/ServerParityAggregateTest.mjs tests/Unit/ServerParityPartsTest.mjs - name: Validate embedded Workflow package authority run: php scripts/ci/test-workflow-package-authority.php diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 0e6463986..039800628 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -313,13 +313,18 @@ jobs: mysql-execution: needs: published-sdk-adapters runs-on: ubuntu-latest - # Corrected marker/range/clock coverage expands the job, and the fixture step - # runs every later profile before failing; scenario budgets stay fixed. - # MySQL runs take 75 to 85 minutes; PostgreSQL's jobs take about 60. - timeout-minutes: 105 + # Each part takes about 20 to 35 minutes; the fixture step runs every + # later profile before failing, and scenario budgets stay fixed. + timeout-minutes: 60 strategy: fail-fast: false + # Each database runs in three parts on separate runners: the native + # database suites and TLS checks, the shared corpus with its profiles and + # restart cases, and the separate cohorts. Every part bootstraps the + # same database and native build; no part depends on another. matrix: + label: [mysql-8.0, existing-php-mysql-matrix, mariadb-10.11] + part: [native-tests, corpus, cohorts] include: - label: mysql-8.0 image: mirror.gcr.io/library/mysql@sha256:62fb722c78b24245ddff1796a0fcee4a49cc5b87e0aaaf20c92d1da9e0a2497b @@ -334,6 +339,7 @@ jobs: # Both expanded originals exhausted the 512 MiB DB fixture ceiling. memory: 1g env: + DW_PARITY_PART: ${{ matrix.part }} RESOURCE_SCOPE: dw-mysql-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.label }} MYSQL_IMAGE: ${{ matrix.image }} DB_MEMORY: ${{ matrix.memory }} @@ -447,13 +453,13 @@ jobs: "$layout" artisan server:bootstrap --force > parity-evidence/published-bootstrap.txt # Compiling the library tests with two jobs peaks near 4.2 GB; a 4 GB limit OOM-killed rustc (exit 101). docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" --cpus=2 --memory=6g --memory-swap=6g \ - -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ + -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 -e DW_PARITY_PART \ -e DW_TEST_MYSQL_URL=mysql://root:parity-disposable-password@parity-mysql:3306/php_ref \ -e DW_EXECUTION_MYSQL_URL=mysql://root:parity-disposable-password@parity-mysql:3306/php_ref \ -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -v "$PWD/rust-target:/target" \ -v "$PWD/parity-evidence:/evidence" -w /source/rust --entrypoint bash \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 -c \ - 'cargo test --locked --offline --lib runtime::mysql:: -- --ignored --skip runtime::mysql::tests::bootstrap_child --nocapture --test-threads=2 > /evidence/mysql-tests.txt && cargo test --locked --offline --test execution -- --nocapture --test-threads=2 > /evidence/mysql-execution-tests.txt && cargo build --locked --offline' + 'if [ "$DW_PARITY_PART" = native-tests ]; then cargo test --locked --offline --lib runtime::mysql:: -- --ignored --skip runtime::mysql::tests::bootstrap_child --nocapture --test-threads=2 > /evidence/mysql-tests.txt && cargo test --locked --offline --test execution -- --nocapture --test-threads=2 > /evidence/mysql-execution-tests.txt; fi && cargo build --locked --offline' docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" \ -e DW_RUST_EXPERIMENTAL=1 -e DB_CONNECTION=mysql -e DB_HOST=parity-mysql -e DB_DATABASE=native_ref \ -e DB_USERNAME=root -e DB_PASSWORD=parity-disposable-password \ @@ -476,6 +482,7 @@ jobs: -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token \ "$image" artisan server:bootstrap --force > parity-evidence/published-upgrade-bootstrap.txt - name: Compare three runtimes, recover killed native nodes and refuse published PHP takeover + if: ${{ matrix.part == 'corpus' }} id: prepare_runtime_comparison run: | set -euo pipefail @@ -671,16 +678,45 @@ jobs: # check has run, so one difference cannot hide the rest. test "$native_record_status" -eq 0 test "$compare_status" -eq 0 + - name: Start the shared native node the cohorts use + id: prepare_cohorts + if: ${{ matrix.part == 'cohorts' }} + run: | + set -euo pipefail + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) + docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ + -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress + docker run -d --name "$RESOURCE_SCOPE-native-b" --network "$RESOURCE_SCOPE" --network-alias=parity-native-b \ + --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ + -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=parity-test-token -e DW_BIND_ADDRESS=0.0.0.0:8080 \ + -e DB_CONNECTION=mysql -e DB_HOST=parity-mysql -e DB_DATABASE=native_ref \ + -e DB_USERNAME=root -e DB_PASSWORD=parity-disposable-password \ + -v "$PWD/rust-target:/target:ro" --tmpfs /state --entrypoint /target/debug/durable-workflow-server \ + mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 + ready=0 + for attempt in $(seq 1 30); do + if docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint curl "$image" \ + -fsS http://parity-native-b:8080/api/ready > parity-evidence/cohort-native-readiness.json; then + ready=1 + break + fi + sleep 1 + done + test "$ready" = 1 - name: Compare separate configured role-token cohort + if: ${{ matrix.part == 'cohorts' }} run: bash scripts/conformance/server-parity/role-profile.sh mysql - name: Back up, take over and resume a separate published PHP upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh mysql - name: Retire upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh mysql - name: Back up, take over and resume a published PHP 2.9.7 upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 env: DW_PARITY_UPGRADE_LABEL: php297 @@ -688,7 +724,7 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh mysql - name: Retire PHP 2.9.7 upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 env: DW_PARITY_UPGRADE_LABEL: php297 @@ -696,35 +732,36 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh mysql - name: Compare corrected markers, ranges, clocks and activity expiry - if: ${{ !cancelled() && steps.prepare_runtime_comparison.outcome == 'success' }} - # The preceding process-loss case deliberately kills native-a. + if: ${{ !cancelled() && matrix.part == 'cohorts' && steps.prepare_cohorts.outcome == 'success' }} + # The cohorts' own native node; the corpus part kills its native-a. run: bash scripts/conformance/server-parity/corrected-marker-shared-profile.sh mysql http://parity-native-b:8080 - name: Retire corrected cohort resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} run: bash scripts/conformance/server-parity/corrected-marker-cleanup.sh mysql - name: Compare installed local external payload consumers - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 run: bash scripts/conformance/server-parity/external-consumer-profile.sh mysql - name: Retire local external consumer resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 run: bash scripts/conformance/server-parity/external-consumer-cleanup.sh mysql - name: Compare published query deadlines and installed Worker authority - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 run: bash scripts/conformance/server-parity/query-authority-cohort.sh mysql - name: Retire query authority resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 run: bash scripts/conformance/server-parity/query-authority-cleanup.sh mysql - name: Compare pinned PHP, embedded and native worker-fencing source cohort - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh mysql - name: Compare SDK temporary database pressure with declared fault limits - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh mysql pressure - name: Verify TLS certificate trust and hostname checks + if: ${{ matrix.part == 'native-tests' }} run: | set -euo pipefail docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" \ @@ -813,26 +850,32 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: mysql-execution-${{ matrix.label }} + name: mysql-execution-${{ matrix.label }}-${{ matrix.part }} path: parity-summary retention-days: 1 postgres-storage: needs: published-sdk-adapters runs-on: ubuntu-latest - # Corrected marker/range/clock coverage expands the job, and the fixture step - # runs every later profile before failing; scenario budgets stay fixed. - # MySQL runs take 75 to 85 minutes; PostgreSQL's jobs take about 60. - timeout-minutes: 105 + # Each part takes about 20 to 35 minutes; the fixture step runs every + # later profile before failing, and scenario budgets stay fixed. + timeout-minutes: 60 strategy: fail-fast: false + # Each database runs in three parts on separate runners: the native + # database suites and TLS checks, the shared corpus with its profiles and + # restart cases, and the separate cohorts. Every part bootstraps the + # same database and native build; no part depends on another. matrix: + label: [existing-php-matrix, postgres-17] + part: [native-tests, corpus, cohorts] include: - label: existing-php-matrix image: mirror.gcr.io/library/postgres@sha256:f1c3376c26f2609ab9f29f71f824103fe2fcd8ee0346485cb6122a4f93df6f94 - label: postgres-17 image: mirror.gcr.io/library/postgres@sha256:3645570cccdfa447589da9f57dd740faa29b30938e861289a5574b6ca6b03826 env: + DW_PARITY_PART: ${{ matrix.part }} RESOURCE_SCOPE: dw-pg-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.label }} POSTGRES_IMAGE: ${{ matrix.image }} steps: @@ -920,13 +963,13 @@ jobs: "$layout" artisan server:bootstrap --force > parity-evidence/published-bootstrap.txt # Compiling the library tests with two jobs peaks near 4.2 GB; a 4 GB limit OOM-killed rustc (exit 101). docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" --cpus=2 --memory=6g --memory-swap=6g \ - -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ + -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 -e DW_PARITY_PART \ -e DW_TEST_POSTGRES_URL=postgres://parity:parity-disposable-password@parity-pg:5432/php_ref \ -e DW_EXECUTION_POSTGRES_URL=postgres://parity:parity-disposable-password@parity-pg:5432/php_ref \ -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -v "$PWD/rust-target:/target" \ -v "$PWD/parity-evidence:/evidence" -w /source/rust --entrypoint bash \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 -c \ - 'cargo test --locked --offline --lib runtime::postgres::tests::qualification_ -- --ignored --nocapture > /evidence/postgres-tests.txt && cargo test --locked --offline --test execution -- --nocapture > /evidence/postgres-execution-tests.txt && cargo build --locked --offline' + 'if [ "$DW_PARITY_PART" = native-tests ]; then cargo test --locked --offline --lib runtime::postgres::tests::qualification_ -- --ignored --nocapture > /evidence/postgres-tests.txt && cargo test --locked --offline --test execution -- --nocapture > /evidence/postgres-execution-tests.txt; fi && cargo build --locked --offline' docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" \ -e DW_RUST_EXPERIMENTAL=1 -e DB_CONNECTION=pgsql -e DB_HOST=parity-pg -e DB_DATABASE=native_ref \ -e DB_USERNAME=parity -e DB_PASSWORD=parity-disposable-password \ @@ -949,6 +992,7 @@ jobs: -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token \ "$image" artisan server:bootstrap --force > parity-evidence/published-upgrade-bootstrap.txt - name: Compare three runtimes, recover killed native nodes and refuse published PHP takeover + if: ${{ matrix.part == 'corpus' }} id: prepare_runtime_comparison env: POSTGRES_IMAGE: ${{ matrix.image }} @@ -1145,18 +1189,47 @@ jobs: # check has run, so one difference cannot hide the rest. test "$native_record_status" -eq 0 test "$compare_status" -eq 0 + - name: Start the shared native node the cohorts use + id: prepare_cohorts + if: ${{ matrix.part == 'cohorts' }} + run: | + set -euo pipefail + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) + docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ + -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress + docker run -d --name "$RESOURCE_SCOPE-native-b" --network "$RESOURCE_SCOPE" --network-alias=parity-native-b \ + --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ + -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=parity-test-token -e DW_BIND_ADDRESS=0.0.0.0:8080 \ + -e DB_CONNECTION=pgsql -e DB_HOST=parity-pg -e DB_DATABASE=native_ref \ + -e DB_USERNAME=parity -e DB_PASSWORD=parity-disposable-password \ + -v "$PWD/rust-target:/target:ro" --tmpfs /state --entrypoint /target/debug/durable-workflow-server \ + mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 + ready=0 + for attempt in $(seq 1 30); do + if docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint curl "$image" \ + -fsS http://parity-native-b:8080/api/ready > parity-evidence/cohort-native-readiness.json; then + ready=1 + break + fi + sleep 1 + done + test "$ready" = 1 - name: Compare separate configured role-token cohort + if: ${{ matrix.part == 'cohorts' }} env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/role-profile.sh pgsql - name: Back up, take over and resume a separate published PHP upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh pgsql - name: Retire upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh pgsql - name: Back up, take over and resume a published PHP 2.9.7 upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 env: DW_PARITY_UPGRADE_LABEL: php297 @@ -1164,7 +1237,7 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh pgsql - name: Retire PHP 2.9.7 upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 env: DW_PARITY_UPGRADE_LABEL: php297 @@ -1172,51 +1245,52 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh pgsql - name: Compare corrected markers, ranges, clocks and activity expiry - if: ${{ !cancelled() && steps.prepare_runtime_comparison.outcome == 'success' }} + if: ${{ !cancelled() && matrix.part == 'cohorts' && steps.prepare_cohorts.outcome == 'success' }} env: POSTGRES_IMAGE: ${{ matrix.image }} - # The preceding process-loss case deliberately kills native-a. + # The cohorts' own native node; the corpus part kills its native-a. run: bash scripts/conformance/server-parity/corrected-marker-shared-profile.sh pgsql http://parity-native-b:8080 - name: Retire corrected cohort resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/corrected-marker-cleanup.sh pgsql - name: Compare installed local external payload consumers - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/external-consumer-profile.sh pgsql - name: Retire local external consumer resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/external-consumer-cleanup.sh pgsql - name: Compare published query deadlines and installed Worker authority - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/query-authority-cohort.sh pgsql - name: Retire query authority resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/query-authority-cleanup.sh pgsql - name: Compare pinned PHP, embedded and native worker-fencing source cohort - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh pgsql - name: Compare SDK temporary database pressure with declared fault limits - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh pgsql pressure - name: Verify TLS certificate trust and hostname checks + if: ${{ matrix.part == 'native-tests' }} env: POSTGRES_IMAGE: ${{ matrix.image }} run: | @@ -1331,17 +1405,26 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: postgres-storage-${{ matrix.label }} + name: postgres-storage-${{ matrix.label }}-${{ matrix.part }} path: parity-summary retention-days: 1 execution-and-codec: needs: published-sdk-adapters runs-on: ubuntu-latest - # Corrected marker/range/clock coverage expands the job, and the fixture step - # runs every later profile before failing; scenario budgets stay fixed. - # MySQL runs take 75 to 85 minutes; PostgreSQL's jobs take about 60. - timeout-minutes: 105 + # Each part takes about 20 to 35 minutes; the fixture step runs every + # later profile before failing, and scenario budgets stay fixed. + timeout-minutes: 60 + strategy: + fail-fast: false + # Three parts on separate runners: the native suites and codec, the + # shared corpus with its profiles and restart cases, and the separate + # cohorts. Every part builds the same native server; no part depends on + # another. + matrix: + part: [native-tests, corpus, cohorts] + env: + DW_PARITY_PART: ${{ matrix.part }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Fetch this run's published SDK adapters @@ -1385,7 +1468,18 @@ jobs: -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -w /source/rust \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ cargo fetch --locked + - name: Build the native server + if: ${{ matrix.part != 'native-tests' }} + run: | + set -euo pipefail + mkdir -p cargo-home rust-target parity-evidence + docker run --rm --user="$(id -u):$(id -g)" --cpus=2 --memory=6g --memory-swap=6g \ + -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ + -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -v "$PWD/rust-target:/target" -w /source/rust \ + mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ + cargo build --locked --offline - name: Execute official Rust codec and cross-decode with published PHP + if: ${{ matrix.part == 'native-tests' }} run: | set -euo pipefail # Failed Rust tests are printed from their evidence files into this step's log. @@ -1415,6 +1509,7 @@ jobs: cargo run --locked --offline --example codec-fixtures -- \ ../tests/Fixtures/ServerParity/Codec/v1.json /evidence/php-codec.json > parity-evidence/cross-decode.json - name: Execute and compare Rust, published PHP and embedded fixtures + if: ${{ matrix.part == 'corpus' }} id: prepare_runtime_comparison env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -1610,22 +1705,56 @@ jobs: # check has run, so one difference cannot hide the rest. test "$native_record_status" -eq 0 test "$compare_status" -eq 0 + - name: Start the shared native node the cohorts use + id: prepare_cohorts + if: ${{ matrix.part == 'cohorts' }} + env: + RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + run: | + set -euo pipefail + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) + docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ + -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress + mkdir -p parity-state parity-evidence + chmod 0777 parity-state parity-evidence + docker network create "$RESOURCE_SCOPE" + docker run -d --name "$RESOURCE_SCOPE-recovered" --network "$RESOURCE_SCOPE" --network-alias=parity-recovered \ + --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ + -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=parity-test-token \ + -e DB_DATABASE=/state/rust.sqlite -e DW_BIND_ADDRESS=0.0.0.0:8080 \ + -v "$PWD/rust-target:/target:ro" -v "$PWD/parity-state:/state" \ + --entrypoint /target/debug/durable-workflow-server \ + mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 + ready=0 + for attempt in $(seq 1 30); do + if docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint curl "$image" \ + -fsS http://parity-recovered:8080/api/ready > parity-evidence/cohort-native-readiness.json; then + ready=1 + break + fi + sleep 1 + done + test "$ready" = 1 - name: Compare separate configured role-token cohort + if: ${{ matrix.part == 'cohorts' }} env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/role-profile.sh sqlite - name: Back up, take over and resume a separate published PHP upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/upgrade-source-profile.sh sqlite - name: Retire upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh sqlite - name: Back up, take over and resume a published PHP 2.9.7 upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -1634,7 +1763,7 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh sqlite - name: Retire PHP 2.9.7 upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -1643,46 +1772,46 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh sqlite - name: Compare corrected markers, ranges, clocks and activity expiry - if: ${{ !cancelled() && steps.prepare_runtime_comparison.outcome == 'success' }} + if: ${{ !cancelled() && matrix.part == 'cohorts' && steps.prepare_cohorts.outcome == 'success' }} env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/corrected-marker-shared-profile.sh sqlite http://parity-recovered:8080 - name: Retire corrected cohort resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/corrected-marker-cleanup.sh sqlite - name: Compare installed local external payload consumers - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/external-consumer-profile.sh sqlite - name: Retire local external consumer resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/external-consumer-cleanup.sh sqlite - name: Compare published query deadlines and installed Worker authority - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/query-authority-cohort.sh sqlite - name: Retire query authority resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/query-authority-cleanup.sh sqlite - name: Compare pinned PHP, embedded and native worker-fencing source cohort - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh sqlite - name: Compare SDK temporary database pressure with declared fault limits - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh sqlite pressure @@ -1742,7 +1871,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: shared-server-fixtures + name: shared-server-fixtures-${{ matrix.part }} path: parity-summary retention-days: 1 if-no-files-found: error diff --git a/tests/Unit/ServerParityPartsTest.mjs b/tests/Unit/ServerParityPartsTest.mjs new file mode 100644 index 000000000..2e57c6de2 --- /dev/null +++ b/tests/Unit/ServerParityPartsTest.mjs @@ -0,0 +1,91 @@ +// Each Shared Server fixtures database job runs in three parts on separate +// runners. Every step either prepares the part (and so runs in every part) or +// belongs to exactly one part, so no check runs twice or not at all. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; + +const workflow = readFileSync('.github/workflows/server-parity.yml', 'utf8'); +const PARTS = ['native-tests', 'corpus', 'cohorts']; +const EVERY_PART = [/^actions\/checkout@/, /^Fetch /, /^Create TLS fixture certificates$/, /^Bootstrap independent /, + /^Collect /, /^Summarize /, /^Retain /]; + +function job(name) { + const start = workflow.indexOf(`\n ${name}:\n`); + assert.ok(start >= 0, name); + const rest = workflow.slice(start + 1); + const end = rest.slice(1).search(/\n {2}[a-z0-9-]+:\n/); + return end < 0 ? rest : rest.slice(0, end + 2); +} + +function steps(block) { + return block.split(/\n(?= {6}- )/).slice(1).map(text => ({ + name: /^ {6}- (?:name: |uses: )(.*)$/m.exec(text)[1], + id: /^ {8}id: (.*)$/m.exec(text)?.[1], + condition: /^ {8}if: (.*)$/m.exec(text)?.[1] ?? null, + text, + })); +} + +// The parts a step runs in, read from its condition. +function partsOf(step) { + if (EVERY_PART.some(pattern => pattern.test(step.name))) return PARTS; + const only = /matrix\.part == '([a-z-]+)'/.exec(step.condition ?? ''); + if (only) return [only[1]]; + const except = /matrix\.part != '([a-z-]+)'/.exec(step.condition ?? ''); + if (except) return PARTS.filter(part => part !== except[1]); + return PARTS; +} + +for (const [name, key] of [['mysql-execution', 'Compare three runtimes'], ['postgres-storage', 'Compare three runtimes'], + ['execution-and-codec', 'Execute and compare Rust']]) { + test(`${name} runs every check in exactly one of three parts`, () => { + const block = job(name); + assert.match(block, /^ {8}part: \[native-tests, corpus, cohorts\]$/m); + assert.match(block, /^ {6}DW_PARITY_PART: \$\{\{ matrix\.part \}\}$/m); + assert.match(block, /^ {4}timeout-minutes: 60$/m); + const all = steps(block); + for (const step of all) { + const parts = partsOf(step); + if (parts.length === PARTS.length) { + assert.ok(EVERY_PART.some(pattern => pattern.test(step.name)), `${name}: "${step.name}" must belong to one part`); + } else if (step.name !== 'Build the native server') { + assert.equal(parts.length, 1, `${name}: "${step.name}"`); + } + } + const inPart = part => all.filter(step => partsOf(step).length === 1 && partsOf(step)[0] === part).map(step => step.name); + assert.ok(inPart('corpus').some(step => step.startsWith(key)), `${name}: the corpus comparison is in the corpus part`); + const cohorts = inPart('cohorts'); + for (const cohort of ['Start the shared native node the cohorts use', 'Compare separate configured role-token cohort', + 'Back up, take over and resume a separate published PHP upgrade source', + 'Back up, take over and resume a published PHP 2.9.7 upgrade source', + 'Compare corrected markers, ranges, clocks and activity expiry', 'Compare installed local external payload consumers', + 'Compare published query deadlines and installed Worker authority', + 'Compare pinned PHP, embedded and native worker-fencing source cohort', + 'Compare SDK temporary database pressure with declared fault limits']) { + assert.ok(cohorts.includes(cohort), `${name}: ${cohort} is in the cohorts part`); + } + // Cleanups still run after a failure within their part. + for (const step of all.filter(step => step.name.startsWith('Retire '))) { + assert.match(step.condition, /^\$\{\{ always\(\) && matrix\.part == 'cohorts' \}\}$/, step.name); + } + // The corrected cohort runs against the cohorts' own native node. + const corrected = all.find(step => step.name.startsWith('Compare corrected markers')); + assert.match(corrected.condition, /steps\.prepare_cohorts\.outcome == 'success'/); + assert.doesNotMatch(block, /steps\.prepare_runtime_comparison\.outcome/); + assert.equal(all.find(step => step.name === 'Start the shared native node the cohorts use').id, 'prepare_cohorts'); + // Each part retains its own summary. + assert.match(block, /^ {10}name: [a-z-]+(?:-\$\{\{ matrix\.label \}\})?-\$\{\{ matrix\.part \}\}$/m); + // The native database suites or the codec suite run once, in native-tests. + const native = inPart('native-tests'); + assert.ok(native.some(step => /^(Verify TLS|Execute official Rust codec)/.test(step)), `${name}: native-tests checks`); + }); +} + +test('the database bootstraps run the native suites only in the native-tests part', () => { + for (const name of ['mysql-execution', 'postgres-storage']) { + const bootstrap = steps(job(name)).find(step => step.name.startsWith('Bootstrap independent ')); + assert.match(bootstrap.text, /-e DW_PARITY_PART \\\n/, name); + assert.match(bootstrap.text, /'if \[ "\$DW_PARITY_PART" = native-tests \]; then cargo test [^']* --test execution [^']*; fi && cargo build --locked --offline'/, name); + } +}); From 98e2a476e41293cfe1847f6e4a6e1b2632c31c18 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:15:51 +0000 Subject: [PATCH 15/17] Compare the fixture jobs' needs lines as text, not through a built expression --- tests/Unit/ServerParityAggregateTest.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/ServerParityAggregateTest.mjs b/tests/Unit/ServerParityAggregateTest.mjs index 50f9c8d4b..73d4c6add 100644 --- a/tests/Unit/ServerParityAggregateTest.mjs +++ b/tests/Unit/ServerParityAggregateTest.mjs @@ -103,7 +103,7 @@ test('the fixture jobs run only through the classifier and the result waits for // that. Every one waits for the SDK adapters, and only deadline health // also waits for the optimized native build. const expected = id === 'codec-deadline-health' ? '[published-sdk-adapters, codec-native-build]' : 'published-sdk-adapters'; - assert.match(blocks[id], new RegExp(`^ {4}needs: ${expected.replace(/[[\]]/g, '\\$&')}$`, 'm'), id); + assert.ok(blocks[id].split('\n').includes(` needs: ${expected}`), `${id} needs ${expected}`); assert.doesNotMatch(blocks[id], /^ {4}if:/m, id); } // Every job other than the classifier and the result is a gated fixture job. From e5d2186e64d6a563e35b4c15772daf36a917e481 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:43:13 +0000 Subject: [PATCH 16/17] Run the three signal profiles in one lane: they share a task queue on the native server The namespace and selected-run signal profiles ran beside the plain signal profile, and on PostgreSQL 17 one's worker showed in another's routing diagnostics (server-parity-sdk-signals). The old serial workflow never overlapped them. A test now reads each profile's queues from its fixtures and refuses any two profiles that share one in different lanes. --- .../server-parity/sdk-direction-lanes.sh | 12 ++++---- .../ServerParitySdkDirectionLanesTest.mjs | 28 ++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/scripts/conformance/server-parity/sdk-direction-lanes.sh b/scripts/conformance/server-parity/sdk-direction-lanes.sh index 59e3dfc80..713a18301 100755 --- a/scripts/conformance/server-parity/sdk-direction-lanes.sh +++ b/scripts/conformance/server-parity/sdk-direction-lanes.sh @@ -3,7 +3,7 @@ # three concurrent lanes. Each profile already has its own containers, PHP and # embedded databases, evidence and state directory, and its own workflow-ID # prefix on the shared native server, so profiles in different lanes cannot -# see each other. Within a lane they run in order, and each profile's PHP +# see each other unless they share a task queue. Within a lane they run in order, and each profile's PHP # server is stopped as soon as its attempt ends, so at most three run at once. # Every profile runs even after another fails; the script fails if any did, # after printing each log. The job's sdk-direction-cleanup steps remove every @@ -17,12 +17,14 @@ if [[ -z "${RESOURCE_SCOPE:-}" ]]; then fi evidence=parity-evidence/sdk-direction-lanes mkdir -p "$evidence" -# Profile variant and its tag (evidence, log and container names). The lanes are -# balanced by the durations recorded in CI (about seven to eight minutes each). +# Profile variant and its tag (evidence, log and container names). Profiles +# that share a task queue on the native server must share a lane: the three +# signal profiles all use server-parity-sdk-signals, and one's workers would +# show in another's routing diagnostics. Every other profile has its own queues. lanes=( "update:sdk-updates update-failure:sdk-update-failures child:sdk-child-directions" - "namespace-signal:sdk-namespace-signals timer:sdk-timers activity:sdk-directions" - "selected-run-signal:sdk-selected-run-signals signal:sdk-signals query:sdk-queries" + "signal:sdk-signals namespace-signal:sdk-namespace-signals selected-run-signal:sdk-selected-run-signals" + "timer:sdk-timers activity:sdk-directions query:sdk-queries" ) run_lane() { local lane=$1 entry variant tag status diff --git a/tests/Unit/ServerParitySdkDirectionLanesTest.mjs b/tests/Unit/ServerParitySdkDirectionLanesTest.mjs index 250603298..76d034787 100644 --- a/tests/Unit/ServerParitySdkDirectionLanesTest.mjs +++ b/tests/Unit/ServerParitySdkDirectionLanesTest.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import {spawnSync} from 'node:child_process'; -import {chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'node:test'; @@ -131,3 +131,29 @@ test('the wrapper refuses to run without an owning resource scope', () => { assert.equal(child.status, 2); assert.match(child.stderr, /Set the owning isolated network\/resource scope/); }); + +// The native server is shared by every lane. Two profiles whose fixtures name +// the same task queue would see each other's workers, so they share a lane. +test('profiles that share a task queue run in the same lane', () => { + const script = readFileSync(LANES, 'utf8'); + const lanes = [...script.slice(script.indexOf('lanes=(')).split(')')[0].matchAll(/^ {2}"([^"]+)"$/gm)] + .map(match => match[1].split(' ').map(entry => entry.split(':')[1])); + assert.deepEqual(lanes.flat().sort(), Object.values(PROFILES).sort(), 'every profile in exactly one lane'); + const queues = tag => { + const directory = new URL(`../Fixtures/ServerParityProfiles/published-${tag}/`, import.meta.url); + const text = readdirSync(directory).filter(name => name.endsWith('.json')) + .map(name => readFileSync(new URL(name, directory), 'utf8')).join('\n'); + return new Set(text.match(/server-parity-sdk-[a-z-]+/g) ?? []); + }; + for (const [index, lane] of lanes.entries()) { + for (const other of lanes.slice(index + 1)) { + for (const tag of lane) { + for (const peer of other) { + const shared = [...queues(tag)].filter(queue => queues(peer).has(queue)); + assert.deepEqual(shared, [], `${tag} and ${peer} share ${shared} but run in different lanes`); + } + } + } + } + assert.ok(queues('sdk-signals').has('server-parity-sdk-signals'), 'queues are read from the fixtures'); +}); From 38697fe8e8baa1a02c3f47f40bfe1986a0b15cc2 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:54:14 +0000 Subject: [PATCH 17/17] Grant the SQLite cohorts' container user its state by ACL, not by making it world-writable SonarCloud refused the new chmod 0777 (githubactions:S2612). Only the published PHP containers' user 1000 needs to write there; acl is on the hosted runner. --- .github/workflows/server-parity.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 039800628..03ae6484f 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -1717,7 +1717,9 @@ jobs: -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress mkdir -p parity-state parity-evidence - chmod 0777 parity-state parity-evidence + # The cohorts' published PHP containers run as 1000:1000; grant that + # user alone, not everyone. + setfacl -m u:1000:rwx,d:u:1000:rwx parity-state parity-evidence docker network create "$RESOURCE_SCOPE" docker run -d --name "$RESOURCE_SCOPE-recovered" --network "$RESOURCE_SCOPE" --network-alias=parity-recovered \ --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \