From f9298b42ca95e30893b383edb6c09b08046c4287 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:30:08 +0000 Subject: [PATCH 1/5] Read current activity barrier history under the parent run lock --- src/V2/Support/ActivityOutcomeRecorder.php | 1 + src/V2/Support/ActivityTimeoutEnforcer.php | 1 + src/V2/Support/ParallelChildGroup.php | 4 +- .../V2ServiceModeParallelChildBarrierTest.php | 162 ++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/V2/Support/ActivityOutcomeRecorder.php b/src/V2/Support/ActivityOutcomeRecorder.php index 692cf0cc4..7ef6551cc 100644 --- a/src/V2/Support/ActivityOutcomeRecorder.php +++ b/src/V2/Support/ActivityOutcomeRecorder.php @@ -495,6 +495,7 @@ public static function record( $run, $parallelMetadataPath, $closedStatus, + lockHistoryForUpdate: true, ) ) { self::projectRun($run->fresh(['instance', 'tasks', 'activityExecutions', 'failures'])); diff --git a/src/V2/Support/ActivityTimeoutEnforcer.php b/src/V2/Support/ActivityTimeoutEnforcer.php index ce8e0a605..0fb63ba3f 100644 --- a/src/V2/Support/ActivityTimeoutEnforcer.php +++ b/src/V2/Support/ActivityTimeoutEnforcer.php @@ -680,6 +680,7 @@ private static function recordTerminalTimeout( $run, $parallelMetadataPath, ActivityStatus::Failed, + lockHistoryForUpdate: true, ) ) { self::projectRun($run->fresh(['instance', 'tasks', 'activityExecutions', 'failures'])); diff --git a/src/V2/Support/ParallelChildGroup.php b/src/V2/Support/ParallelChildGroup.php index 84be4ac9a..1e0ce4768 100644 --- a/src/V2/Support/ParallelChildGroup.php +++ b/src/V2/Support/ParallelChildGroup.php @@ -276,13 +276,15 @@ public static function shouldWakeParentOnChildClosure( public static function shouldWakeParentOnActivityClosure( WorkflowRun $parentRun, array $metadata, - ActivityStatus $closedActivityStatus + ActivityStatus $closedActivityStatus, + bool $lockHistoryForUpdate = false, ): bool { return self::shouldWakeParentOnClosure( $parentRun, self::normalizedPath($metadata), 'activity', $closedActivityStatus, + $lockHistoryForUpdate, ); } diff --git a/tests/Feature/V2/V2ServiceModeParallelChildBarrierTest.php b/tests/Feature/V2/V2ServiceModeParallelChildBarrierTest.php index 4cd3283e6..29e20aaf6 100644 --- a/tests/Feature/V2/V2ServiceModeParallelChildBarrierTest.php +++ b/tests/Feature/V2/V2ServiceModeParallelChildBarrierTest.php @@ -4,11 +4,14 @@ namespace Tests\Feature\V2; +use Illuminate\Database\Events\QueryExecuted; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Tests\TestCase; use Workflow\Serializers\Serializer; +use Workflow\V2\Contracts\ActivityTaskBridge; use Workflow\V2\Contracts\WorkflowTaskBridge; use Workflow\V2\Enums\HistoryEventType; use Workflow\V2\Enums\RunStatus; @@ -344,6 +347,165 @@ public function testConcurrentChildCompletionsSerializeOneBarrierWakeOnParentRun $this->assertSame(1, $this->openParentWorkflowTaskCount($parentRun)); } + public static function activityHistoryInterleavings(): array + { + $cases = []; + foreach ([false, true] as $named) { + foreach ([2, 6, 25] as $size) { + $cases[($named ? 'named' : 'default') . '-' . $size] = [$size, $named, false]; + } + $cases[($named ? 'named' : 'default') . '-nested'] = [6, $named, true]; + } + + return $cases; + } + + #[DataProvider('activityHistoryInterleavings')] + public function testActivityClosureReadsCurrentHistoryAfterAnEarlierSnapshot( + int $size, + bool $named, + bool $nested, + ): void { + if (DB::connection()->getDriverName() !== 'mysql') { + $this->markTestSkipped('This interleaving exercises MySQL repeatable-read snapshots.'); + } + + $originalStorage = config('workflows.storage.connection'); + $default = DB::getDefaultConnection(); + $reader = $named ? 'activity_barrier_reader' : $default; + $writer = 'activity_barrier_writer'; + config([ + 'database.connections.' . $writer => config('database.connections.' . $default), + ]); + if ($named) { + config([ + 'database.connections.' . $reader => config('database.connections.' . $default), + ]); + } + config([ + 'workflows.storage.connection' => $named ? $reader : null, + ]); + $observedSnapshot = false; + + try { + $readConnection = DB::connection($reader); + $writeConnection = DB::connection($writer); + $readConnection->statement('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $writeConnection->statement('SET SESSION innodb_lock_wait_timeout = 5'); + $this->assertNotSame( + $readConnection->scalar('SELECT CONNECTION_ID()'), + $writeConnection->scalar('SELECT CONNECTION_ID()'), + ); + + [$run, $attempts] = $this->stageActivityGroup($size, $nested); + $activities = $this->app->make(ActivityTaskBridge::class); + foreach (array_slice($attempts, 0, -2) as $attempt) { + $this->assertTrue($activities->complete($attempt, 'earlier sibling')['recorded']); + $this->assertSame(0, $this->openParentWorkflowTaskCount($run)); + } + $first = $attempts[$size - 2]; + $last = $attempts[$size - 1]; + $readStorage = config('workflows.storage.connection'); + + DB::listen(function (QueryExecuted $event) use ( + &$observedSnapshot, + $reader, + $writer, + $first, + $run, + $size, + $activities, + $readStorage, + ): void { + if ($observedSnapshot || $event->connectionName !== $reader + || $event->connection->transactionLevel() === 0 + || ! str_contains(strtolower($event->sql), 'exists') + || ! str_contains($event->sql, 'activity_attempts')) { + return; + } + $observedSnapshot = true; + // The real final handler has established its RR snapshot but + // has not acquired the parent-run lock. Commit its sibling on + // a separate connection before that handler can continue. + config([ + 'workflows.storage.connection' => $writer, + ]); + try { + $this->assertTrue($activities->complete($first, 'concurrent sibling')['recorded']); + $this->assertSame(0, $this->openParentWorkflowTaskCount($run)); + $this->assertSame($size - 1, $run->historyEvents() + ->where('event_type', HistoryEventType::ActivityCompleted->value)->count()); + } finally { + config([ + 'workflows.storage.connection' => $readStorage, + ]); + } + // Prove that merely reloading an ordinary relation would + // still miss the committed sibling on this connection. + $this->assertSame($size - 2, $event->connection->table('workflow_history_events') + ->where('workflow_run_id', $run->id) + ->where('event_type', HistoryEventType::ActivityCompleted->value)->count()); + }); + + $this->assertTrue($activities->complete($last, 'last sibling')['recorded']); + $this->assertTrue($observedSnapshot); + $this->assertSame($size, $run->historyEvents() + ->where('event_type', HistoryEventType::ActivityCompleted->value)->count()); + $this->assertSame(1, $this->openParentWorkflowTaskCount($run)); + $this->assertFalse($activities->complete($last, 'duplicate')['recorded']); + $this->assertSame(1, $this->openParentWorkflowTaskCount($run)); + } finally { + config([ + 'workflows.storage.connection' => $originalStorage, + ]); + DB::purge($writer); + if ($named) { + DB::purge($reader); + } + } + } + + /** + * @return array{WorkflowRun, list} + */ + private function stageActivityGroup(int $size, bool $nested): array + { + $run = $this->createWaitingRun(); + $task = $this->createLeasedTask($run); + $commands = []; + for ($index = 0; $index < $size; $index++) { + $path = [$this->parallelEntry('parallel-activities:1:' . $size, 'activity', 1, $size, $index)]; + if ($nested && $index >= $size - 2) { + $path[] = $this->parallelEntry( + 'parallel-activities:' . ($size - 1) . ':2', + 'activity', + $size - 1, + 2, + $index - ($size - 2) + ); + } + $commands[] = [ + 'type' => 'schedule_activity', + 'activity_type' => 'test-activity-history', + 'arguments' => Serializer::serialize([]), + ...$path[array_key_last($path)], + 'parallel_group_path' => $path, + ]; + } + $reply = $this->bridge->complete($task->id, $commands); + $this->assertTrue($reply['completed']); + $this->assertCount($size, $reply['created_task_ids']); + $attempts = []; + $activities = $this->app->make(ActivityTaskBridge::class); + foreach ($reply['created_task_ids'] as $id) { + $claim = $activities->claimStatus($id, 'activity-history-' . $id); + $this->assertTrue($claim['claimed']); + $attempts[] = $claim['activity_attempt_id']; + } + + return [$run, $attempts]; + } + /** * @param list>> $parallelPaths * @param list> $commandOverrides From 4d563f4dbbd9895e7842bf1a14613cd7cdc42296 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Fri, 6 Nov 2026 14:13:09 -0600 Subject: [PATCH 2/5] Merge pull request #1054 from durable-workflow/general/297-bound-release-caller-20261106 Read the selected release contract and isolate verifier scratch --- scripts/ci/check-packagist-release.sh | 7 +- .../ci/check-platform-conformance-mirror.sh | 3 +- .../ci/laravel-embedded-upgrade-matrix.php | 21 ++++- scripts/ci/test-packagist-release.sh | 66 +++++++++++++++ ...est-platform-conformance-resolver-audit.sh | 63 +++++++++++++-- .../V2/LaravelEmbeddedUpgradeContractTest.php | 81 +++++++++++++++++++ 6 files changed, 229 insertions(+), 12 deletions(-) create mode 100755 scripts/ci/test-packagist-release.sh diff --git a/scripts/ci/check-packagist-release.sh b/scripts/ci/check-packagist-release.sh index 33bd61458..783957ae8 100755 --- a/scripts/ci/check-packagist-release.sh +++ b/scripts/ci/check-packagist-release.sh @@ -44,7 +44,12 @@ fi package_url="https://repo.packagist.org/p2/${package_name}.json" tmp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" -package_path="${tmp_dir}/packagist-package.json" +umask 077 +package_path="$(mktemp "${tmp_dir}/workflow-packagist.XXXXXX.json")" +trap 'rm -f -- "$package_path"' EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM attempt=1 while [ "$attempt" -le "$attempts" ]; do diff --git a/scripts/ci/check-platform-conformance-mirror.sh b/scripts/ci/check-platform-conformance-mirror.sh index fece02c2f..2b5ddbd82 100755 --- a/scripts/ci/check-platform-conformance-mirror.sh +++ b/scripts/ci/check-platform-conformance-mirror.sh @@ -29,7 +29,8 @@ download_with_retry() { attempt=1 while [ "$attempt" -le "$attempts" ]; do - if curl -fsSL --retry 3 --retry-all-errors --connect-timeout 10 --max-time 30 \ + if curl -fsSL --proto '=https' --proto-redir '=https' \ + --retry 3 --retry-all-errors --connect-timeout 10 --max-time 30 \ -o "$download_file" "$download_url"; then return fi diff --git a/scripts/ci/laravel-embedded-upgrade-matrix.php b/scripts/ci/laravel-embedded-upgrade-matrix.php index d933f7cdf..e2d6f7688 100755 --- a/scripts/ci/laravel-embedded-upgrade-matrix.php +++ b/scripts/ci/laravel-embedded-upgrade-matrix.php @@ -4,10 +4,16 @@ declare(strict_types=1); $scope = null; +$contractPath = null; foreach (array_slice($argv, 1) as $argument) { - if (str_starts_with($argument, '--scope=')) { + if (str_starts_with($argument, '--scope=') && $scope === null) { $scope = substr($argument, strlen('--scope=')); + } elseif (str_starts_with($argument, '--contract-path=') && $contractPath === null) { + $contractPath = substr($argument, strlen('--contract-path=')); + } else { + fwrite(STDERR, "Unknown or repeated matrix argument.\n"); + exit(1); } } @@ -16,8 +22,17 @@ exit(1); } -$contractPath = dirname(__DIR__, 2) . '/resources/laravel-embedded-upgrade-contract.json'; -$contract = json_decode((string) file_get_contents($contractPath), true, 512, JSON_THROW_ON_ERROR); +$contractPath ??= dirname(__DIR__, 2) . '/resources/laravel-embedded-upgrade-contract.json'; +if (! is_file($contractPath) || is_link($contractPath)) { + fwrite(STDERR, "The Laravel embedded upgrade contract must be a regular file.\n"); + exit(1); +} +$contractBytes = file_get_contents($contractPath, false, null, 0, 65_537); +if ($contractBytes === false || strlen($contractBytes) > 65_536) { + fwrite(STDERR, "The Laravel embedded upgrade contract exceeds its byte limit.\n"); + exit(1); +} +$contract = json_decode($contractBytes, true, 512, JSON_THROW_ON_ERROR); $intersection = $contract['supported_intersection'] ?? null; if (! is_array($intersection) || ! is_array($intersection['cells'] ?? null)) { diff --git a/scripts/ci/test-packagist-release.sh b/scripts/ci/test-packagist-release.sh new file mode 100755 index 000000000..13231d6af --- /dev/null +++ b/scripts/ci/test-packagist-release.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env sh + +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)" +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/workflow-packagist-test.XXXXXX")" +trap 'rm -rf "$tmp_dir"' EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +mkdir "$tmp_dir/bin" "$tmp_dir/runtime" +printf '%s\n' 'untouched' > "$tmp_dir/victim" +ln -s "$tmp_dir/victim" "$tmp_dir/runtime/packagist-package.json" +cat > "$tmp_dir/bin/curl" <<'CURL' +#!/usr/bin/env sh +set -eu +output='' +while [ "$#" -gt 0 ]; do + case "$1" in + -o) output="$2"; shift 2 ;; + --proto|--proto-redir|--retry|--connect-timeout|--max-time) shift 2 ;; + *) shift ;; + esac +done +printf '%s\n' "$output" >> "$PACKAGIST_TEST_PATHS" +if [ "${PACKAGIST_TEST_FAIL:-0}" = 1 ]; then exit 22; fi +printf '%s\n' '{"packages":{"fixture/workflow":[{"version":"2.20.4","dist":{"url":"https://package.example/dist.zip"}}]}}' > "$output" +stat -c '%a' "$output" >> "$PACKAGIST_TEST_MODES" +CURL +chmod 700 "$tmp_dir/bin/curl" +export PACKAGIST_TEST_PATHS="$tmp_dir/paths" +export PACKAGIST_TEST_MODES="$tmp_dir/modes" +export RUNNER_TEMP="$tmp_dir/runtime" +export GITHUB_STEP_SUMMARY="$tmp_dir/summary" +export PACKAGIST_PACKAGE_NAME=fixture/workflow +export PACKAGIST_PACKAGE_VERSION=2.20.4 +export PACKAGIST_RELEASE_ATTEMPTS=1 +export PACKAGIST_RELEASE_RETRY_SLEEP=0 +PATH="$tmp_dir/bin:$PATH" +export PATH + +"$repo_root/scripts/ci/check-packagist-release.sh" +"$repo_root/scripts/ci/check-packagist-release.sh" +if PACKAGIST_TEST_FAIL=1 "$repo_root/scripts/ci/check-packagist-release.sh" > "$tmp_dir/failure" 2>&1; then + printf '%s\n' 'Failed Packagist transport unexpectedly passed.' >&2 + exit 1 +fi +if [ "$(cat "$tmp_dir/victim")" != untouched ]; then + printf '%s\n' 'Packagist verification followed the old shared-path symlink.' >&2 + exit 1 +fi +if [ "$(sort -u "$tmp_dir/paths" | wc -l | tr -d ' ')" -ne 3 ]; then + printf '%s\n' 'Packagist temporary paths were reused.' >&2 + exit 1 +fi +while IFS= read -r path; do + if [ -e "$path" ] || [ -L "$path" ]; then + printf '%s\n' 'Packagist temporary file survived its invocation.' >&2 + exit 1 + fi +done < "$tmp_dir/paths" +if [ "$(sort -u "$tmp_dir/modes")" != 600 ]; then + printf '%s\n' 'Packagist temporary files were not private.' >&2 + exit 1 +fi +printf '%s\n' 'Packagist verification owns distinct private temporary files and cleans success and failure.' diff --git a/scripts/ci/test-platform-conformance-resolver-audit.sh b/scripts/ci/test-platform-conformance-resolver-audit.sh index c05230871..335558acb 100755 --- a/scripts/ci/test-platform-conformance-resolver-audit.sh +++ b/scripts/ci/test-platform-conformance-resolver-audit.sh @@ -9,7 +9,43 @@ mirror_file="$tmp_dir/workflow-mirror.json" api_file="$tmp_dir/worker-protocol-api.openapi.yaml" stream_file="$tmp_dir/worker-protocol-stream.asyncapi.yaml" output_file="$tmp_dir/audit-output.txt" -trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM +trap 'status=$?; if [ "$status" -ne 0 ] && [ -f "$output_file" ]; then cat "$output_file" >&2; fi; rm -rf "$tmp_dir"' EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +real_curl="$(command -v curl)" +mkdir "$tmp_dir/bin" +cat > "$tmp_dir/bin/curl" <<'CURL' +#!/usr/bin/env sh +set -eu +for argument in "$@"; do + case "$argument" in + file://*) exec "$RESOLVER_AUDIT_REAL_CURL" "$@" ;; + esac +done +output='' +url='' +while [ "$#" -gt 0 ]; do + case "$1" in + -o) output="$2"; shift 2 ;; + --proto|--proto-redir|--retry|--connect-timeout|--max-time) shift 2 ;; + -fsSL|--retry-all-errors) shift ;; + *) url="$1"; shift ;; + esac +done +case "$url" in + https://resolver.example/authority.json) source=public-authority.json ;; + https://resolver.example/api.yaml) source=worker-protocol-api.openapi.yaml ;; + https://resolver.example/stream.yaml) source=worker-protocol-stream.asyncapi.yaml ;; + *) exit 22 ;; +esac +cp "$RESOLVER_AUDIT_FIXTURES/$source" "$output" +CURL +chmod 700 "$tmp_dir/bin/curl" +export RESOLVER_AUDIT_REAL_CURL="$real_curl" +export RESOLVER_AUDIT_FIXTURES="$tmp_dir" +PATH="$tmp_dir/bin:$PATH" +export PATH printf '%s\n' 'openapi: 3.1.0' > "$api_file" printf '%s\n' 'asyncapi: 3.0.0' > "$stream_file" @@ -49,18 +85,17 @@ write_manifest() { } run_audit() { - PLATFORM_CONFORMANCE_AUTHORITY_URL="file://$authority_file" \ + PLATFORM_CONFORMANCE_AUTHORITY_URL="${audit_authority_url:-https://resolver.example/authority.json}" \ WORKFLOW_PLATFORM_CONFORMANCE_MIRROR_FILE="$mirror_file" \ PLATFORM_CONFORMANCE_AUTHORITY_ATTEMPTS=1 \ PLATFORM_CONFORMANCE_AUTHORITY_RETRY_SLEEP=0 \ "$repo_root/scripts/ci/check-platform-conformance-mirror.sh" } -write_manifest "file://$api_file" "file://$stream_file" "$api_digest" "$stream_digest" +write_manifest 'https://resolver.example/api.yaml' 'https://resolver.example/stream.yaml' "$api_digest" "$stream_digest" run_audit > "$output_file" 2>&1 -missing_file="$tmp_dir/missing-worker-protocol-api.openapi.yaml" -write_manifest "file://$missing_file" "file://$stream_file" "$api_digest" "$stream_digest" +write_manifest 'https://resolver.example/missing.yaml' 'https://resolver.example/stream.yaml' "$api_digest" "$stream_digest" if run_audit > "$output_file" 2>&1; then printf '%s\n' 'Platform conformance resolver audit accepted a missing advertised resolver.' >&2 exit 1 @@ -74,7 +109,7 @@ case "$(cat "$output_file")" in esac wrong_digest='sha256:0000000000000000000000000000000000000000000000000000000000000000' -write_manifest "file://$api_file" "file://$stream_file" "$wrong_digest" "$stream_digest" +write_manifest 'https://resolver.example/api.yaml' 'https://resolver.example/stream.yaml' "$wrong_digest" "$stream_digest" if run_audit > "$output_file" 2>&1; then printf '%s\n' 'Platform conformance resolver audit accepted resolver bytes with the wrong digest.' >&2 exit 1 @@ -87,4 +122,18 @@ case "$(cat "$output_file")" in ;; esac -printf '%s\n' 'Platform conformance resolver audit rejects missing and divergent advertised protocol bytes.' +write_manifest 'https://resolver.example/api.yaml' 'https://resolver.example/stream.yaml' "$api_digest" "$stream_digest" +audit_authority_url="file://$authority_file" +if run_audit > "$output_file" 2>&1; then + printf '%s\n' 'Platform authority download accepted a file URL.' >&2 + exit 1 +fi +unset audit_authority_url +write_manifest "file://$api_file" 'https://resolver.example/stream.yaml' "$api_digest" "$stream_digest" +if run_audit > "$output_file" 2>&1; then + printf '%s\n' 'Platform resolver download accepted a file URL.' >&2 + exit 1 +fi + +printf '%s\n' 'Platform conformance resolver audit preserves byte checks and rejects non-HTTPS inputs.' +"$repo_root/scripts/ci/test-packagist-release.sh" diff --git a/tests/Unit/V2/LaravelEmbeddedUpgradeContractTest.php b/tests/Unit/V2/LaravelEmbeddedUpgradeContractTest.php index d62c49103..5a75e49b3 100644 --- a/tests/Unit/V2/LaravelEmbeddedUpgradeContractTest.php +++ b/tests/Unit/V2/LaravelEmbeddedUpgradeContractTest.php @@ -91,6 +91,87 @@ public function testQualificationMatricesAreDerivedFromTheShippedContract(): voi $this->assertSame('published-package', $releaseWorkflow['jobs']['laravel-upgrade']['needs']); } + public function testPublishedMatrixUsesTheExplicitProductContract(): void + { + $path = tempnam(sys_get_temp_dir(), 'workflow-tag-contract-'); + $this->assertNotFalse($path); + $cells = [[ + 'php' => '8.2', + 'laravel' => '11', + ], [ + 'php' => '8.4', + 'laravel' => '13', + ]]; + + try { + file_put_contents($path, json_encode([ + 'supported_intersection' => [ + 'cells' => $cells, + ], + ], JSON_THROW_ON_ERROR)); + $process = $this->matrixProcess(['--scope=published', '--contract-path=' . $path]); + $process->mustRun(); + + $this->assertSame([ + 'include' => $cells, + ], json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR)); + } finally { + unlink($path); + } + } + + public function testMissingOrOversizedProductContractsNeverFallBackToTheHarness(): void + { + $path = tempnam(sys_get_temp_dir(), 'workflow-invalid-contract-'); + $this->assertNotFalse($path); + + try { + file_put_contents($path, str_repeat(' ', 65_537)); + $oversized = $this->matrixProcess(['--scope=published', '--contract-path=' . $path]); + $this->assertSame(1, $oversized->run()); + $this->assertSame('', $oversized->getOutput()); + + $missing = $this->matrixProcess(['--scope=published', '--contract-path=' . $path . '.missing']); + $this->assertSame(1, $missing->run()); + $this->assertSame('', $missing->getOutput()); + } finally { + unlink($path); + } + } + + public function testMatrixInputRefusesSymlinksAndAmbiguousArguments(): void + { + $path = tempnam(sys_get_temp_dir(), 'workflow-contract-link-'); + $this->assertNotFalse($path); + $link = $path . '.link'; + + try { + symlink($path, $link); + foreach ([['--scope=published', '--contract-path=' . $link], + ['--scope=published', '--contract-path=', '--contract-path=' . $path], + ['--scope=published', '--scope=source'], ['--scope=published', '--unknown=ignored']] as $arguments) { + $process = $this->matrixProcess($arguments); + $this->assertSame(1, $process->run()); + $this->assertSame('', $process->getOutput()); + } + } finally { + unlink($link); + unlink($path); + } + } + + /** + * @param list $arguments + */ + private function matrixProcess(array $arguments): Process + { + return new Process([ + PHP_BINARY, + dirname(__DIR__, 3) . '/scripts/ci/laravel-embedded-upgrade-matrix.php', + ...$arguments, + ]); + } + /** * @return array{include: list} */ From 01f00cd2a592f4a6f71fc727a9e4500afb89f3b2 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:24:07 +0000 Subject: [PATCH 3/5] ci: bind published package verification to the accepted PHP profile --- .github/workflows/release-verification.yml | 221 ++++++++++++++++-- .../ci/check-platform-conformance-mirror.sh | 9 + scripts/ci/test-build-routing.py | 133 +++++++++++ ...est-platform-conformance-resolver-audit.sh | 60 +++++ 4 files changed, 398 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index 7b761cb73..1ae9f508e 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -21,53 +21,224 @@ permissions: jobs: published-package: name: Verify published Workflow package - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + GITHUB_TOKEN: '' + GH_TOKEN: '' + GITHUB_ENTERPRISE_TOKEN: '' + GH_ENTERPRISE_TOKEN: '' + COMPOSER_AUTH: '' + WORKFLOW_COMPOSER_TRUST_VERIFIER: ${{ github.workspace }}/.qualification-control/.github/actions/verify-composer-trust/verify.php outputs: laravel_published_matrix: ${{ steps.laravel-matrix.outputs.matrix }} release_tag: ${{ steps.release.outputs.tag }} steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 - with: - php-version: '8.3' - tools: composer - - - name: Derive supported Laravel upgrade matrix - id: laravel-matrix - run: echo "matrix=$(php scripts/ci/laravel-embedded-upgrade-matrix.php --scope=published)" >> "$GITHUB_OUTPUT" - - - name: Resolve stable release tag + - name: Admit one stable release verification id: release shell: bash env: INPUT_TAG: ${{ inputs.tag }} run: | set -euo pipefail - if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then - tag="$INPUT_TAG" - else - tag="$GITHUB_REF_NAME" - fi - tag="${tag#v}" - if ! printf '%s\n' "$tag" | grep -Eq '^2\.[0-9]+\.[0-9]+$'; then - echo "::error::Expected a stable Workflow 2.x tag, got: $tag" + test "$GITHUB_REPOSITORY" = durable-workflow/workflow + test "$GITHUB_RUN_ATTEMPT" = 1 + [[ "$GITHUB_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]] + case "$GITHUB_EVENT_NAME" in + workflow_dispatch) + test "$GITHUB_REF" = refs/heads/main + tag="$INPUT_TAG" + ;; + push) + test "$GITHUB_REF_TYPE" = tag + tag="$GITHUB_REF_NAME" + test "$GITHUB_REF" = "refs/tags/$tag" + ;; + *) exit 1 ;; + esac + if [[ ! "$tag" =~ ^2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo 'Expected one literal stable Workflow 2.x tag' >&2 exit 1 fi - echo "tag=$tag" >> "$GITHUB_OUTPUT" + printf 'tag=%s\n' "$tag" >> "$GITHUB_OUTPUT" + - name: Check out the maintained verification harness + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.workflow_sha }} + fetch-depth: 0 + persist-credentials: false + - name: Bind the immutable product tag to its commit + id: product + shell: bash + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_WORKFLOW_SHA" + revision="$(git rev-parse --verify "refs/tags/$RELEASE_TAG^{commit}")" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + if [ "$GITHUB_EVENT_NAME" = push ]; then + test "$(git rev-parse --verify "$GITHUB_SHA^{commit}")" = "$revision" + fi + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + - name: Check out immutable product metadata + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ steps.product.outputs.revision }} + path: .release-product + persist-credentials: false + - name: Verify the product train without executing product source + id: product_metadata + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + /usr/bin/python3 -I -S -B - <<'PYTHON' + import json,os,pathlib,stat + path=pathlib.Path('.release-product/composer.json') + metadata=path.lstat() + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size>65536: + raise ValueError('Product metadata must be a bounded regular file') + with path.open() as source: + product=json.load(source) + if (product.get('name')!='durable-workflow/workflow' + or product.get('extra',{}).get('durable-workflow',{}).get('product-train')!=os.environ['RELEASE_TAG']): + raise ValueError('The immutable product train differs from the selected tag') + PYTHON + - name: Check out the accepted PHP terminal control source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: durable-workflow/.github + ref: 8ba5caae2546f3d5f41b7616c5722b9b337b8fc0 + path: .qualification-control + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.12' + - name: Create caller-owned Composer home, cache and scratch + id: composer_state + run: | + /usr/bin/python3 -I -S -B - <<'PYTHON' + import json,os,pathlib,shutil,stat,tempfile + root=pathlib.Path(tempfile.mkdtemp(prefix='workflow-release-composer-',dir=os.environ['RUNNER_TEMP'])) + try: + for name in ('home','cache','tmp'): + (root/name).mkdir(mode=0o700) + metadata=root.lstat() + identity=[metadata.st_dev,metadata.st_ino,metadata.st_uid,stat.S_IMODE(metadata.st_mode)] + with open(os.environ['GITHUB_ENV'],'a') as output: + for name,child in (('COMPOSER_HOME','home'),('COMPOSER_CACHE_DIR','cache'),('TMPDIR','tmp')): + output.write(f'{name}={root/child}\n') + with open(os.environ['GITHUB_OUTPUT'],'a') as output: + output.write(f'directory={root}\nidentity={json.dumps(identity)}\n') + except BaseException: + shutil.rmtree(root) + raise + PYTHON + - name: Select authenticated dependency trust + id: pip_trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + clients: pip,python,composer,curl,git + - name: Set up the exact accepted initial PHP profile + id: php_setup + uses: durable-workflow/.github/.github/actions/setup-php-linux@8ba5caae2546f3d5f41b7616c5722b9b337b8fc0 # bound-linux-php + with: + php-version: '8.3' + ini-file: production + tools: composer:2.10.3,phpunit:11.5.57 + coverage: none + - name: Derive the published matrix from immutable product metadata + id: laravel-matrix + run: | + matrix="$(php scripts/ci/laravel-embedded-upgrade-matrix.php --scope=published --contract-path=.release-product/resources/laravel-embedded-upgrade-contract.json)" + printf 'matrix=%s\n' "$matrix" >> "$GITHUB_OUTPUT" - name: Wait for Packagist release env: PACKAGIST_PACKAGE_NAME: durable-workflow/workflow PACKAGIST_PACKAGE_VERSION: ${{ steps.release.outputs.tag }} + RUNNER_TEMP: ${{ env.TMPDIR }} run: scripts/ci/check-packagist-release.sh - + - name: Verify Composer trust before published product requests + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust - name: Verify published platform contract env: WORKFLOW_PLATFORM_CONFORMANCE_VERSION: ${{ steps.release.outputs.tag }} + RUNNER_TEMP: ${{ env.TMPDIR }} run: scripts/ci/check-platform-conformance-mirror.sh + - name: Revalidate PHP after product commands and clean owned state + if: ${{ always() && steps.composer_state.outcome != 'skipped' }} + env: + SETUP_RECEIPT: ${{ steps.php_setup.outputs.bootstrap-receipt }} + PIP_RECEIPT: ${{ steps.pip_trust.outputs.pip-state }} + PIP_CREATED: ${{ steps.pip_trust.outputs.pip-created }} + PIP_ROOT_IDENTITY: ${{ steps.pip_trust.outputs.pip-root-identity }} + PIP_RECEIPT_IDENTITY: ${{ steps.pip_trust.outputs.pip-receipt-identity }} + COMPOSER_DIRECTORY: ${{ steps.composer_state.outputs.directory }} + COMPOSER_DIRECTORY_IDENTITY: ${{ steps.composer_state.outputs.identity }} + PRODUCT_STATUS: ${{ job.status }} + LD_PRELOAD: '' + LD_LIBRARY_PATH: '' + LD_AUDIT: '' + run: | + /usr/bin/python3 -I -S -B - <<'PY' + import json,os,pathlib,shutil,stat,subprocess,sys,tempfile + runner_temp=pathlib.Path(os.environ['RUNNER_TEMP']) + composer_text=os.environ.get('COMPOSER_DIRECTORY','') + composer_root=pathlib.Path(composer_text) if composer_text else None + composer_identity=os.environ.get('COMPOSER_DIRECTORY_IDENTITY','') + try: + control_source='8ba5caae2546f3d5f41b7616c5722b9b337b8fc0' + control_directory=pathlib.Path.cwd()/'.qualification-control' + def control_git(*arguments): + return subprocess.check_output(['/usr/bin/git','-C',str(control_directory),*arguments],text=True).strip() + if control_git('rev-parse','HEAD')!=control_source: + raise ValueError('PHP terminal control source differs from its immutable binding') + subprocess.run(['/usr/bin/git','-C',str(control_directory),'diff','--exit-code',control_source,'--'],check=True,stdout=subprocess.DEVNULL) + if control_git('ls-files','--others'): + raise ValueError('PHP terminal control source contains untracked files') + sys.path.insert(0,str(control_directory)) + from scripts import php_linux_bootstrap as bootstrap + from scripts import php_linux_qualification as qualification + pip_state={key:os.environ.get(name,'') for key,name in ( + ('receipt','PIP_RECEIPT'),('created','PIP_CREATED'), + ('root_identity','PIP_ROOT_IDENTITY'),('receipt_identity','PIP_RECEIPT_IDENTITY'))} + receipt_text=os.environ.get('SETUP_RECEIPT','') + receipt=pathlib.Path(receipt_text) if receipt_text else None + product_status=os.environ['PRODUCT_STATUS'] + context={key:os.environ[key] for key in ('GITHUB_REPOSITORY','GITHUB_WORKFLOW_SHA', + 'GITHUB_SHA','GITHUB_REF','GITHUB_EVENT_NAME','GITHUB_RUN_ID','GITHUB_RUN_ATTEMPT')} + with tempfile.TemporaryDirectory(prefix='workflow-release-terminal-',dir=runner_temp) as directory: + private=pathlib.Path(directory) + (private/'home').mkdir(mode=0o700) + (private/'tmp').mkdir(mode=0o700) + closed=bootstrap.clean_environment(dict(os.environ),str(private/'home')) + os.environ.clear() + os.environ.update(closed) + def publish(evidence): + evidence.update(source=control_source,php_helper_source=control_source, + pip_predecessor_source='242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5',control=context, + caller_product_status=product_status) + text=json.dumps(evidence,sort_keys=True) + if len(text.encode())>65536: + raise ValueError('PHP terminal evidence exceeds its fixed bound') + print(text) + with open(os.environ['GITHUB_STEP_SUMMARY'],'a') as output: + output.write('Workflow published-package post-product terminal qualification\n\n'+text+'\n') + qualification.qualify(receipt,runner_temp,closed,publish,pip_state=pip_state) + finally: + if composer_root is not None: + metadata=composer_root.lstat() + actual=[metadata.st_dev,metadata.st_ino,metadata.st_uid,stat.S_IMODE(metadata.st_mode)] + if (composer_root.parent!=runner_temp or not composer_root.name.startswith('workflow-release-composer-') + or not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid!=os.getuid() + or actual!=json.loads(composer_identity)): + raise ValueError('Exact caller-owned Composer state changed; cleanup refused') + shutil.rmtree(composer_root) + if os.path.lexists(composer_root): + raise ValueError('Caller-owned Composer state remains after cleanup') + print(json.dumps({'composer_state_cleanup':'pass'})) + PY laravel-upgrade: name: Laravel ${{ matrix.laravel }} / PHP ${{ matrix.php }} published upgrade diff --git a/scripts/ci/check-platform-conformance-mirror.sh b/scripts/ci/check-platform-conformance-mirror.sh index 2b5ddbd82..bd47c8ed1 100755 --- a/scripts/ci/check-platform-conformance-mirror.sh +++ b/scripts/ci/check-platform-conformance-mirror.sh @@ -7,6 +7,7 @@ version="${WORKFLOW_PLATFORM_CONFORMANCE_VERSION:-${GITHUB_REF_NAME:-}}" mirror_file="${WORKFLOW_PLATFORM_CONFORMANCE_MIRROR_FILE:-}" attempts="${PLATFORM_CONFORMANCE_AUTHORITY_ATTEMPTS:-6}" sleep_seconds="${PLATFORM_CONFORMANCE_AUTHORITY_RETRY_SLEEP:-20}" +composer_trust_verifier="${WORKFLOW_COMPOSER_TRUST_VERIFIER:-}" repo_root="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)" tmp_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/workflow-platform-conformance.XXXXXX")" authority_file="$tmp_dir/public-authority.json" @@ -16,6 +17,12 @@ history_resolver_file="$tmp_dir/history-export-resolver.json" worker_protocol_resolvers_file="$tmp_dir/worker-protocol-resolvers.tsv" trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM +verify_project_composer_trust() { + if [ -n "$composer_trust_verifier" ]; then + php "$composer_trust_verifier" "$(command -v composer)" "$tmp_dir" + fi +} + version="${version#v}" case "$attempts" in @@ -55,7 +62,9 @@ else exit 2 fi + verify_project_composer_trust composer --working-dir="$tmp_dir" init --name=durable-workflow/conformance-release-audit --no-interaction + verify_project_composer_trust composer --working-dir="$tmp_dir" require --no-interaction --no-progress --prefer-dist \ "durable-workflow/workflow:$version" diff --git a/scripts/ci/test-build-routing.py b/scripts/ci/test-build-routing.py index 8be187f5d..ac0aa0e11 100644 --- a/scripts/ci/test-build-routing.py +++ b/scripts/ci/test-build-routing.py @@ -4,10 +4,12 @@ from __future__ import annotations import json +import os import re import shutil import subprocess import tempfile +import textwrap import unittest from pathlib import Path @@ -444,5 +446,136 @@ def job_source(self, name: str) -> str: return workflow_job_source(self.build, name) +class PublishedPackageAdmissionTest(unittest.TestCase): + def step_script(self, identity: str) -> str: + source = workflow_job_source(RELEASE_VERIFICATION_WORKFLOW.read_text(), "published-package") + for step in source.split(" - "): + if re.search(rf"(?m)^ id: {re.escape(identity)}$", step): + return textwrap.dedent(step.split(" run: |\n", 1)[1]).rstrip() + self.fail(f"Missing executable release step {identity}") + + def environment(self, output: Path) -> dict[str, str]: + return { + "PATH": os.defpath, + "GITHUB_REPOSITORY": "durable-workflow/workflow", + "GITHUB_RUN_ATTEMPT": "1", + "GITHUB_WORKFLOW_SHA": "a" * 40, + "GITHUB_SHA": "b" * 40, + "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_REF": "refs/heads/main", + "GITHUB_REF_TYPE": "branch", + "GITHUB_REF_NAME": "main", + "INPUT_TAG": "2.20.4", + "RELEASE_TAG": "2.20.4", + "GITHUB_OUTPUT": str(output), + } + + def test_release_admission_accepts_only_the_bound_stable_context(self) -> None: + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "outputs" + accepted = [ + {}, + {"GITHUB_EVENT_NAME": "push", "GITHUB_REF_TYPE": "tag", + "GITHUB_REF": "refs/tags/2.20.4", "GITHUB_REF_NAME": "2.20.4"}, + ] + refused = [ + {"GITHUB_REPOSITORY": "other/workflow"}, + {"GITHUB_RUN_ATTEMPT": "2"}, + {"GITHUB_WORKFLOW_SHA": "main"}, + {"GITHUB_EVENT_NAME": "pull_request"}, + {"GITHUB_REF": "refs/tags/2.20.4"}, + {"INPUT_TAG": "v2.20.4"}, + {"INPUT_TAG": "2.20.4-rc1"}, + {"INPUT_TAG": "2.020.4"}, + {"INPUT_TAG": "../../main"}, + {"GITHUB_EVENT_NAME": "push", "GITHUB_REF_TYPE": "tag", + "GITHUB_REF": "refs/tags/2.20.3", "GITHUB_REF_NAME": "2.20.4"}, + ] + for changes, expected in [(case, True) for case in accepted] + [(case, False) for case in refused]: + with self.subTest(changes=changes): + output.unlink(missing_ok=True) + result = subprocess.run( + ["bash", "-c", self.step_script("release")], + env=self.environment(output) | changes, capture_output=True, text=True, timeout=5, + ) + self.assertEqual(expected, result.returncode == 0, result.stderr) + if expected: + self.assertEqual("tag=2.20.4\n", output.read_text()) + else: + self.assertFalse(output.exists()) + + def test_product_binding_peels_the_tag_without_replacing_the_harness(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = root / "outputs" + def git(*arguments: str) -> str: + return subprocess.check_output( + ["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", *arguments], + cwd=root, text=True, stderr=subprocess.DEVNULL, timeout=5, + ).strip() + git("init", "-q") + git("commit", "--allow-empty", "-qm", "Product") + product = git("rev-parse", "HEAD") + git("tag", "-am", "Product tag", "2.20.4") + git("commit", "--allow-empty", "-qm", "Maintained harness") + harness = git("rev-parse", "HEAD") + environment = self.environment(output) | {"GITHUB_WORKFLOW_SHA": harness} + result = subprocess.run( + ["bash", "-c", self.step_script("product")], cwd=root, + env=environment, capture_output=True, text=True, timeout=5, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(f"revision={product}\n", output.read_text()) + self.assertEqual(harness, git("rev-parse", "HEAD")) + output.unlink() + result = subprocess.run( + ["bash", "-c", self.step_script("product")], cwd=root, + env=environment | {"GITHUB_EVENT_NAME": "push", "GITHUB_SHA": harness}, + capture_output=True, text=True, timeout=5, + ) + self.assertNotEqual(0, result.returncode) + self.assertFalse(output.exists()) + + def test_product_metadata_must_match_the_selected_tag(self) -> None: + script = self.step_script("product_metadata") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + product = root / ".release-product" + product.mkdir() + manifest = product / "composer.json" + for train, expected in [("2.20.4", True), ("2.21.0", False)]: + with self.subTest(train=train): + manifest.write_text(json.dumps({"name": "durable-workflow/workflow", "extra": { + "durable-workflow": {"product-train": train}, + }})) + result = subprocess.run( + ["bash", "-c", script], cwd=root, + env=self.environment(root / "outputs"), capture_output=True, text=True, timeout=5, + ) + self.assertEqual(expected, result.returncode == 0, result.stderr) + + def test_matrix_failure_does_not_publish_an_empty_successful_output(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = root / "outputs" + php = root / "php" + environment = self.environment(output) | {"PATH": str(root) + os.pathsep + os.defpath} + php.write_text("#!/bin/sh\nexit 23\n") + php.chmod(0o700) + result = subprocess.run( + ["bash", "-e", "-c", self.step_script("laravel-matrix")], + env=environment, capture_output=True, text=True, timeout=5, + ) + self.assertEqual(23, result.returncode) + self.assertFalse(output.exists()) + php.write_text("#!/bin/sh\nprintf '%s\\n' '{\"include\":[]}'\n") + result = subprocess.run( + ["bash", "-e", "-c", self.step_script("laravel-matrix")], + env=environment, capture_output=True, text=True, timeout=5, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('matrix={"include":[]}\n', output.read_text()) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/ci/test-platform-conformance-resolver-audit.sh b/scripts/ci/test-platform-conformance-resolver-audit.sh index 335558acb..1ba29f447 100755 --- a/scripts/ci/test-platform-conformance-resolver-audit.sh +++ b/scripts/ci/test-platform-conformance-resolver-audit.sh @@ -136,4 +136,64 @@ if run_audit > "$output_file" 2>&1; then fi printf '%s\n' 'Platform conformance resolver audit preserves byte checks and rejects non-HTTPS inputs.' + +# Exercise the actual project creation path without installing a package. The +# final fake require always stops before any published-package PHP can execute. +mkdir "$tmp_dir/composer-bin" "$tmp_dir/composer-scratch" +cat > "$tmp_dir/composer-bin/curl" <<'CURL' +#!/usr/bin/env sh +set -eu +while [ "$#" -gt 0 ]; do + if [ "$1" = -o ]; then printf '{}\n' > "$2"; exit 0; fi + shift +done +exit 1 +CURL +cat > "$tmp_dir/composer-bin/php" <<'PHP' +#!/usr/bin/env sh +set -eu +test "$1" = "$COMPOSER_AUDIT_VERIFIER" +test "$2" = "$COMPOSER_AUDIT_BIN/composer" +test -d "$3" +printf 'verify\n' >> "$COMPOSER_AUDIT_LOG" +if [ "$COMPOSER_AUDIT_REFUSE" = before-init ]; then exit 71; fi +if [ "$COMPOSER_AUDIT_REFUSE" = before-require ] && [ -f "$3/composer.json" ]; then exit 72; fi +PHP +cat > "$tmp_dir/composer-bin/composer" <<'COMPOSER' +#!/usr/bin/env sh +set -eu +project="${1#--working-dir=}" +test "$project" != "$1" +printf '%s\n' "$2" >> "$COMPOSER_AUDIT_LOG" +case "$2" in + init) printf '{}\n' > "$project/composer.json" ;; + require) exit 73 ;; + *) exit 1 ;; +esac +COMPOSER +chmod 700 "$tmp_dir/composer-bin/"* + +for refusal in before-init before-require none; do + composer_log="$tmp_dir/composer-$refusal.log" + result=0 + PATH="$tmp_dir/composer-bin:$PATH" \ + COMPOSER_AUDIT_BIN="$tmp_dir/composer-bin" \ + COMPOSER_AUDIT_VERIFIER="$tmp_dir/inert-verifier.php" \ + COMPOSER_AUDIT_LOG="$composer_log" \ + COMPOSER_AUDIT_REFUSE="$refusal" \ + WORKFLOW_COMPOSER_TRUST_VERIFIER="$tmp_dir/inert-verifier.php" \ + WORKFLOW_PLATFORM_CONFORMANCE_VERSION=2.20.4 \ + WORKFLOW_PLATFORM_CONFORMANCE_MIRROR_FILE='' \ + RUNNER_TEMP="$tmp_dir/composer-scratch" \ + "$repo_root/scripts/ci/check-platform-conformance-mirror.sh" > "$output_file" 2>&1 || result=$? + case "$refusal" in + before-init) expected_status=71; expected_calls='verify' ;; + before-require) expected_status=72; expected_calls="$(printf 'verify\ninit\nverify')" ;; + none) expected_status=73; expected_calls="$(printf 'verify\ninit\nverify\nrequire')" ;; + esac + test "$result" -eq "$expected_status" + test "$(cat "$composer_log")" = "$expected_calls" + test -z "$(ls -A "$tmp_dir/composer-scratch")" +done +printf '%s\n' 'Composer trust is checked before init and again after project creation; refusals stop the request and clean scratch.' "$repo_root/scripts/ci/test-packagist-release.sh" From d04a1855963acb4e0cae0416182cd89c8203489a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:10:39 +0000 Subject: [PATCH 4/5] Prepare the compatible 2.20.5 maintenance release and CI route --- .github/workflows/php.yml | 4 ++-- CHANGELOG.md | 11 +++++++++++ composer.json | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 5c3150cc3..25f2b6808 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -2,9 +2,9 @@ name: build on: push: - branches: [main] + branches: [main, 2.20.x] pull_request: - branches: [main] + branches: [main, 2.20.x] workflow_dispatch: inputs: sqlite_timer_qualification: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9443ccf81..5fe0c7d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +## 2.20.5 - 2026-11-07 + +- Prevent a successful parallel activity group from missing its continuation + under MySQL repeatable read (#1052). The terminal activity handler now reads + current history while holding the parent-run lock, so a sibling completion + committed after its earlier snapshot is visible. Named storage and nested + all groups follow the same path; duplicate completion still wakes once. +- This is a prevention change with no schema, payload or public API change. + It does not automatically resume runs already waiting without a continuation. + Rolling back restores the missed-wake defect. + ## 2.20.3 - 2026-11-04 - Rollback, embedded Workflow package: 2.20.3 changes no schema, history diff --git a/composer.json b/composer.json index 2009a01b2..63c3c8a6d 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.20.4", + "product-train": "2.20.5", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, From 218ccaf8f201195559fa740cf391e4169656f424 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:55:36 +0000 Subject: [PATCH 5/5] docs: preserve 2.20.4 release history in maintenance patch --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe0c7d8f..32177db81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ It does not automatically resume runs already waiting without a continuation. Rolling back restores the missed-wake defect. +## 2.20.4 - 2026-11-05 + +- Fix an embedded MySQL/InnoDB REPEATABLE READ deadlock between + continue-as-new and unrelated signal admission. Caller-owned transactions + retain their original read views, pending default/named signal handoffs + remain durable, and user workflow code is not automatically replayed to + recover from the deadlock. + + ## 2.20.3 - 2026-11-04 - Rollback, embedded Workflow package: 2.20.3 changes no schema, history