Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f92648c
CI: run the required checks for GitHub merge queue groups
rmcdaniel Oct 24, 2026
afd7712
CI: contract test for the merge queue triggers
rmcdaniel Oct 24, 2026
2ba8c47
Expect the merge-queue base revision in the PHPUnit Feature contracts
rmcdaniel Oct 24, 2026
2bc50de
Keep SonarQube and unfiltered push scans out of the merge-queue change
rmcdaniel Oct 24, 2026
3cf93e4
Check queue-group cancellation only where a workflow has a concurrenc…
rmcdaniel Oct 24, 2026
5e75436
CI: run the SDK direction profiles in three concurrent lanes
rmcdaniel Oct 24, 2026
076d1ac
Merge remote-tracking branch 'origin/main' into rust-325-ci-sdk-lanes
rmcdaniel Oct 24, 2026
bf73ac2
Compare activity lease renewals as instants, not RFC3339 text
rmcdaniel Oct 24, 2026
c04e9c6
Report one Shared Server fixtures result on every pull request
rmcdaniel Oct 24, 2026
09450da
Run the fixtures classifier and result through shell helpers
rmcdaniel Oct 24, 2026
f9decbb
Merge remote-tracking branch 'origin/main' into rust-325-merge-group-v2
rmcdaniel Oct 24, 2026
337fe8d
CI: build the deadline-health native binary in its own job
rmcdaniel Oct 24, 2026
f877ccc
Escape every pattern metacharacter in path filters
rmcdaniel Oct 24, 2026
c834849
Stop each SDK direction profile's PHP server after its attempt, and t…
rmcdaniel Oct 24, 2026
62b1cee
Classify pull request changes from NUL-separated paths, and run every…
rmcdaniel Oct 24, 2026
9173ee2
Merge #741 into the fixtures result: gate the optimized native build …
rmcdaniel Oct 24, 2026
12a1af0
CI: run each fixture database job in three parallel parts
rmcdaniel Oct 24, 2026
98e2a47
Compare the fixture jobs' needs lines as text, not through a built ex…
rmcdaniel Oct 24, 2026
4face31
Merge branch 'rust-325-merge-group-v2' into rust-325-ci-db-parts
rmcdaniel Oct 24, 2026
e5d2186
Run the three signal profiles in one lane: they share a task queue on…
rmcdaniel Oct 24, 2026
261a557
Merge remote-tracking branch 'origin/rust-325-ci-sdk-lanes' into rust…
rmcdaniel Oct 24, 2026
2271b0b
Merge remote-tracking branch 'origin/rust-325-merge-group-v2' into ru…
rmcdaniel Oct 24, 2026
38697fe
Grant the SQLite cohorts' container user its state by ACL, not by mak…
rmcdaniel Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/phpunit-feature.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches:
- main
pull_request:
merge_group:
workflow_dispatch:
inputs:
corpus_base_ref:
Expand Down Expand Up @@ -71,6 +72,12 @@ 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: Report one Shared Server fixtures result on every pull request
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

Expand All @@ -85,15 +92,15 @@ 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
run: composer audit --locked --no-interaction

- 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
Expand Down Expand Up @@ -167,7 +174,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
Expand Down Expand Up @@ -262,7 +269,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"
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/public-boundary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: Public Boundary

on:
pull_request:
merge_group:
push:
workflow_dispatch:

Expand All @@ -28,6 +29,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
Expand Down
521 changes: 274 additions & 247 deletions .github/workflows/server-parity.yml

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions rust/tests/execution/activity_deadlines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions scripts/conformance/server-parity/aggregate.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions scripts/conformance/server-parity/aggregate.sh
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions scripts/conformance/server-parity/changes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/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 `/`. 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 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)));
}

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. 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').toString('utf8').trim().split(' ');
if (parents.length !== 3 || parents[2] !== headSha) throw new Error('HEAD is not this pull request\'s merge commit');
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`};
}
return matched.length > 0
? {run: true, reason: `fixture inputs changed: ${matched.map(file => JSON.stringify(file)).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}`);
}
5 changes: 5 additions & 0 deletions scripts/conformance/server-parity/changes.sh
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions scripts/conformance/server-parity/sdk-direction-lanes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/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 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
# 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}"
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). 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"
"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
for entry in $lane; do
variant=${entry%%:*} tag=${entry#*:} status=0
bash scripts/conformance/server-parity/sdk-direction-profile.sh "$backend" "$native_url" "$variant" \
> "$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
run_lane "$lane" &
done
wait
failed=0
for lane in "${lanes[@]}"; do
for entry in $lane; do
tag=${entry#*:}
status=$(cat "$evidence/$tag.status" 2>/dev/null || echo missing)
echo "::group::$tag (exit $status)"
cat "$evidence/$tag.log"
echo "::endgroup::"
if [[ "$status" != 0 ]]; then
echo "::error::SDK direction profile $tag exited $status" >&2
failed=1
fi
done
done
exit "$failed"
2 changes: 2 additions & 0 deletions scripts/conformance/server-parity/sdk-directions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions tests/Unit/MergeGroupTriggersTest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// The Actions checks a GitHub merge queue waits for must run on merge_group.
// 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';

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', '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) ?? [];
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 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:\s*$/m);
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', '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);
});
2 changes: 1 addition & 1 deletion tests/Unit/OpenApiDocumentEvolutionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/PhpunitFeatureWorkflowContractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading