From 241ace075d2c7134f90fa054a116dcf4952b2d48 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:14:57 +0000 Subject: [PATCH 1/2] Build standalone CLI with verified PHP 8.4.27 runtime --- .github/workflows/build.yml | 3 ++ .github/workflows/release.yml | 68 +++++++++++++++++++++++-- scripts/ci/probe-standalone-runtime.php | 9 ++++ scripts/ci/test-standalone-runtime.js | 57 +++++++++++++++++++++ scripts/ci/verify-standalone-runtime.js | 65 +++++++++++++++++++++++ tests/ReleaseInstallerContractTest.php | 33 +++++++++++- 6 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 scripts/ci/probe-standalone-runtime.php create mode 100644 scripts/ci/test-standalone-runtime.js create mode 100644 scripts/ci/verify-standalone-runtime.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 57b2181..ca1f92c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,6 +82,8 @@ jobs: node --check scripts/ci/release-version.js node --check scripts/ci/verify-cli-release-channel.js node --check scripts/ci/verify-cli-upgrade-result.js + node --check scripts/ci/verify-standalone-runtime.js + php -l scripts/ci/probe-standalone-runtime.php bash -n scripts/ci/verify-release-tag-source.sh bash -n scripts/ci/establish-native-prerequisites.sh bash -n scripts/ci/reconcile-release-metadata.sh @@ -92,6 +94,7 @@ jobs: run: | python scripts/ci/test-native-prerequisites.py node --test scripts/ci/test-release-workflow-metadata.js + node --test scripts/ci/test-standalone-runtime.js node --test scripts/ci/test-cli-release-channel.js node --test scripts/ci/test-cli-upgrade-result.js node --test scripts/ci/test-release-metadata.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d66fc0..52cf841 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -456,7 +456,7 @@ jobs: MUSL_SHA256: a9a118bbe84d8764da0ea0d28b3ab3fae8477fc7e4085d90102b8596fc7c75e4 MUSL_VERSION: '1.2.5' NATIVE_PREREQUISITE_CACHE_SCHEMA: 'v1' - PHP_VERSION: '8.4.23' + PHP_VERSION: '8.4.27' PHPMICRO_CACHE_SCHEMA: 'v1' PHPMICRO_UNCACHED_BASELINE_SECONDS: '480' SPC_DOWNLOAD_OUTER_ATTEMPTS: '4' @@ -479,6 +479,8 @@ jobs: sparse-checkout: | .github/workflows/release.yml scripts/ci/establish-native-prerequisites.sh + scripts/ci/probe-standalone-runtime.php + scripts/ci/verify-standalone-runtime.js sparse-checkout-cone-mode: false - name: Download PHAR artifact @@ -804,6 +806,23 @@ jobs: "sha256=$micro_sha256" \ > phpmicro-cache.manifest + - name: Verify produced standalone PHP runtime + id: runtime-policy + timeout-minutes: 2 + shell: bash + working-directory: build/.tools + run: | + set -euo pipefail + policy="$GITHUB_WORKSPACE/release-toolchain-control/scripts/ci" + ./spc micro:combine "$policy/probe-standalone-runtime.php" \ + --output=standalone-runtime-probe + chmod +x standalone-runtime-probe + ./standalone-runtime-probe > standalone-runtime.json + node "$policy/verify-standalone-runtime.js" --expected-version "$PHP_VERSION" \ + < standalone-runtime.json > standalone-runtime-verification.json + cat standalone-runtime-verification.json + rm standalone-runtime-probe + - name: Record phpmicro toolchain timing if: always() working-directory: build/.tools @@ -820,6 +839,7 @@ jobs: NATIVE_PREREQUISITE_IDENTITY: ${{ steps.native-prerequisites.outputs.identity }} NATIVE_PREREQUISITE_MODE: ${{ steps.native-prerequisites.outputs.mode }} NATIVE_PREREQUISITE_SOURCE: ${{ steps.native-prerequisites.outputs.source }} + RUNTIME_POLICY_OUTCOME: ${{ steps.runtime-policy.outcome }} run: | set -euo pipefail finished_at="$(date +%s)" @@ -831,7 +851,8 @@ jobs: cache_mode="cold-fallback" fi outcome="failure" - if [ -s buildroot/bin/micro.sfx ] && [ -s phpmicro-cache.manifest ]; then + if [ "$RUNTIME_POLICY_OUTCOME" = "success" ] && + [ -s buildroot/bin/micro.sfx ] && [ -s phpmicro-cache.manifest ]; then outcome="success" fi @@ -903,6 +924,8 @@ jobs: build/.tools/log/ build/.tools/native-prerequisite-evidence.json build/.tools/phpmicro-timing.json + build/.tools/standalone-runtime.json + build/.tools/standalone-runtime-verification.json if-no-files-found: warn - name: Combine PHAR into standalone binary @@ -988,7 +1011,7 @@ jobs: runs-on: windows-2022 env: GITHUB_TOKEN: ${{ github.token }} - PHP_VERSION_WINDOWS: '8.4.23' + PHP_VERSION_WINDOWS: '8.4.27' PHPMICRO_CACHE_SCHEMA: 'v1' PHPMICRO_UNCACHED_BASELINE_SECONDS: '480' SPC_DOWNLOAD_OUTER_ATTEMPTS: '4' @@ -1011,7 +1034,10 @@ jobs: ref: ${{ needs.resolve-release.outputs.control_commit }} path: release-toolchain-control persist-credentials: false - sparse-checkout: .github/workflows/release.yml + sparse-checkout: | + .github/workflows/release.yml + scripts/ci/probe-standalone-runtime.php + scripts/ci/verify-standalone-runtime.js sparse-checkout-cone-mode: false - name: Download PHAR artifact @@ -1328,6 +1354,34 @@ jobs: "sha256=$microSha256" ) | Set-Content -Path phpmicro-cache.manifest -Encoding ascii + - name: Verify produced standalone PHP runtime + id: runtime-policy + timeout-minutes: 2 + shell: pwsh + working-directory: build\.tools + run: | + $ErrorActionPreference = "Stop" + $policy = Join-Path $env:GITHUB_WORKSPACE "release-toolchain-control\scripts\ci" + $probe = Join-Path $policy "probe-standalone-runtime.php" + $verifier = Join-Path $policy "verify-standalone-runtime.js" + $runtimeProbe = Join-Path (Get-Location).Path "standalone-runtime-probe.exe" + .\spc.exe micro:combine $probe --output=$runtimeProbe + if ($LASTEXITCODE -ne 0) { + throw "Could not package the produced-runtime probe" + } + $evidence = & $runtimeProbe + if ($LASTEXITCODE -ne 0) { + throw "The produced-runtime probe failed" + } + $evidence | Set-Content -Path standalone-runtime.json -Encoding utf8 + $evidence | node $verifier --expected-version "$env:PHP_VERSION_WINDOWS" ` + > standalone-runtime-verification.json + if ($LASTEXITCODE -ne 0) { + throw "The produced runtime failed the PHP release policy" + } + Get-Content -Raw standalone-runtime-verification.json + Remove-Item -Force $runtimeProbe + - name: Record phpmicro toolchain timing if: always() shell: pwsh @@ -1341,6 +1395,7 @@ jobs: PLATFORM: windows-x86_64 RUN_PURPOSE: ${{ needs.resolve-release.outputs.cache_warm == 'true' && 'cache-warm' || 'release' }} STARTED_AT: ${{ steps.phpmicro-timing-start.outputs.started_at }} + RUNTIME_POLICY_OUTCOME: ${{ steps.runtime-policy.outcome }} run: | $finishedAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $elapsedSeconds = $finishedAt - [long]$env:STARTED_AT @@ -1351,7 +1406,8 @@ jobs: $cacheMode = "cold-fallback" } $outcome = if ( - (Test-Path buildroot\bin\micro.sfx -PathType Leaf) ` + $env:RUNTIME_POLICY_OUTCOME -eq "success" ` + -and (Test-Path buildroot\bin\micro.sfx -PathType Leaf) ` -and (Test-Path phpmicro-cache.manifest -PathType Leaf) ) { "success" } else { "failure" } @@ -1386,6 +1442,8 @@ jobs: path: | build/.tools/log/ build/.tools/phpmicro-timing.json + build/.tools/standalone-runtime.json + build/.tools/standalone-runtime-verification.json if-no-files-found: warn - name: Combine PHAR into standalone binary diff --git a/scripts/ci/probe-standalone-runtime.php b/scripts/ci/probe-standalone-runtime.php new file mode 100644 index 0000000..58f16de --- /dev/null +++ b/scripts/ci/probe-standalone-runtime.php @@ -0,0 +1,9 @@ + PHP_VERSION, + 'php_version_id' => PHP_VERSION_ID, + 'sapi' => PHP_SAPI, +], JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/ci/test-standalone-runtime.js b/scripts/ci/test-standalone-runtime.js new file mode 100644 index 0000000..06116a1 --- /dev/null +++ b/scripts/ci/test-standalone-runtime.js @@ -0,0 +1,57 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const {spawnSync} = require('node:child_process'); +const path = require('node:path'); +const test = require('node:test'); +const {verifyStandaloneRuntime} = require('./verify-standalone-runtime'); + +const verifier = path.join(__dirname, 'verify-standalone-runtime.js'); +const fixed = {php_version: '8.4.27', php_version_id: 80427, sapi: 'micro'}; + +test('accepts the configured fixed micro runtime and later supported patch pins', () => { + for (const patch of [27, 28]) { + const version = `8.4.${patch}`; + const result = verifyStandaloneRuntime({php_version: version, php_version_id: 80400 + patch, sapi: 'micro'}, version); + assert.equal(result.verified, true); + assert.equal(result.php_version, version); + assert.equal(result.minimum_php_version, '8.4.27'); + } +}); + +test('refuses an old runtime even when a caller supplies its matching old pin', () => { + assert.throws(() => verifyStandaloneRuntime({php_version: '8.4.23', php_version_id: 80423, sapi: 'micro'}, '8.4.23'), /at least 8\.4\.27/); + assert.throws(() => verifyStandaloneRuntime({php_version: '8.4.23', php_version_id: 80423, sapi: 'micro'}, '8.4.27'), /does not match/); +}); + +test('refuses external PHP, prereleases and runtimes from a different cache identity', () => { + assert.throws(() => verifyStandaloneRuntime({...fixed, sapi: 'cli'}, '8.4.27'), /produced micro SAPI/); + assert.throws(() => verifyStandaloneRuntime({...fixed, php_version: '8.4.27RC1'}, '8.4.27'), /does not match/); + assert.throws(() => verifyStandaloneRuntime({...fixed, php_version: '8.4.28', php_version_id: 80428}, '8.4.27'), /does not match/); +}); + +test('refuses incomplete or contradictory runtime metadata', () => { + for (const evidence of [null, [], {}, {...fixed, php_version_id: '80427'}, {...fixed, php_version_id: 80423}, {...fixed, php_version_id: 80427.5}]) { + assert.throws(() => verifyStandaloneRuntime(evidence, '8.4.27')); + } + for (const version of ['8.5.0', '8.4.27-dev', 'v8.4.27', '8.4.027', null]) { + assert.throws(() => verifyStandaloneRuntime(fixed, version), /stable 8\.4/); + } +}); + +test('the command fails closed on malformed, empty, noisy or oversized probe output', () => { + for (const input of ['', '{', 'warning\n' + JSON.stringify(fixed), ' '.repeat(4097)]) { + const result = spawnSync(process.execPath, [verifier, '--expected-version', '8.4.27'], {input, encoding: 'utf8'}); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + } +}); + +test('the command emits a bounded verification receipt only for valid runtime evidence', () => { + const result = spawnSync(process.execPath, [verifier, '--expected-version', '8.4.27'], {input: JSON.stringify(fixed), encoding: 'utf8'}); + assert.equal(result.status, 0); + assert.equal(result.stderr, ''); + const receipt = JSON.parse(result.stdout); + assert.equal(receipt.verified, true); + assert.equal(receipt.php_version_id, 80427); +}); diff --git a/scripts/ci/verify-standalone-runtime.js b/scripts/ci/verify-standalone-runtime.js new file mode 100644 index 0000000..7c1d0ad --- /dev/null +++ b/scripts/ci/verify-standalone-runtime.js @@ -0,0 +1,65 @@ +'use strict'; + +const fs = require('node:fs'); + +const MINIMUM_PHP_VERSION = '8.4.27'; +const MINIMUM_PHP_VERSION_ID = 80427; + +function verifyStandaloneRuntime(evidence, expectedVersion) { + const match = typeof expectedVersion === 'string' && /^8\.4\.(0|[1-9]\d*)$/.exec(expectedVersion); + if (!match) { + throw new Error('standalone PHP pin must be a stable 8.4 patch version'); + } + const expectedVersionId = 80400 + Number(match[1]); + if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId < MINIMUM_PHP_VERSION_ID) { + throw new Error(`standalone PHP pin must be at least ${MINIMUM_PHP_VERSION}`); + } + if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) { + throw new Error('standalone runtime probe must return a JSON object'); + } + if (evidence.sapi !== 'micro') { + throw new Error('standalone runtime probe must execute in the produced micro SAPI'); + } + if (evidence.php_version !== expectedVersion) { + throw new Error('produced PHP runtime does not match the configured stable pin'); + } + if (!Number.isInteger(evidence.php_version_id) || evidence.php_version_id !== expectedVersionId) { + throw new Error('produced PHP runtime version ID does not match the configured pin'); + } + + return Object.freeze({ + schema: 'durable-workflow.cli.standalone-runtime/v1', + minimum_php_version: MINIMUM_PHP_VERSION, + expected_php_version: expectedVersion, + php_version: evidence.php_version, + php_version_id: evidence.php_version_id, + sapi: evidence.sapi, + verified: true, + }); +} + +module.exports = {verifyStandaloneRuntime}; + +if (require.main === module) { + try { + const arguments_ = process.argv.slice(2); + if (arguments_.length !== 2 || arguments_[0] !== '--expected-version') { + throw new Error('usage: verify-standalone-runtime.js --expected-version '); + } + const input = fs.readFileSync(0, 'utf8'); + if (Buffer.byteLength(input) > 4096) { + throw new Error('standalone runtime probe output exceeds the size limit'); + } + let evidence; + try { + evidence = JSON.parse(input); + } catch { + throw new Error('standalone runtime probe must return valid JSON'); + } + const result = verifyStandaloneRuntime(evidence, arguments_[1]); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + process.stderr.write(`Standalone runtime verification failed: ${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/tests/ReleaseInstallerContractTest.php b/tests/ReleaseInstallerContractTest.php index adc867a..54d48bd 100644 --- a/tests/ReleaseInstallerContractTest.php +++ b/tests/ReleaseInstallerContractTest.php @@ -260,8 +260,8 @@ public function test_release_phpmicro_toolchain_is_pinned_verified_and_trust_sco $releaseWorkflow, 'SPC_REVISION: 4318ef8fa32a02460ec1554746674a7bc42b49fa', )); - self::assertSame(1, substr_count($releaseWorkflow, "PHP_VERSION: '8.4.23'")); - self::assertSame(1, substr_count($releaseWorkflow, "PHP_VERSION_WINDOWS: '8.4.23'")); + self::assertSame(1, substr_count($releaseWorkflow, "PHP_VERSION: '8.4.27'")); + self::assertSame(1, substr_count($releaseWorkflow, "PHP_VERSION_WINDOWS: '8.4.27'")); self::assertStringContainsString( 'spc_sha256: 523ba4279c54c7a377156c0dd3a36adf92ee64b01e9a7f5e9e2ec084b8e458e5', $releaseWorkflow, @@ -387,6 +387,35 @@ public function test_release_phpmicro_toolchain_is_pinned_verified_and_trust_sco self::assertStringContainsString('previous_uncached_baseline_seconds', $releaseWorkflow); } + public function test_release_checks_the_produced_runtime_before_combining_the_application(): void + { + $workflow = self::readRepoFile('.github/workflows/release.yml'); + $unixStart = strpos($workflow, "\n build-binary:"); + $windowsStart = strpos($workflow, "\n build-binary-windows:"); + self::assertIsInt($unixStart); + self::assertIsInt($windowsStart); + + foreach ([ + substr($workflow, $unixStart, $windowsStart - $unixStart), + substr($workflow, $windowsStart), + ] as $platform) { + $guard = strpos($platform, '- name: Verify produced standalone PHP runtime'); + $combine = strpos($platform, '- name: Combine PHAR into standalone binary'); + self::assertIsInt($guard); + self::assertIsInt($combine); + self::assertLessThan($combine, $guard); + $stepEnd = strpos($platform, '- name: Record phpmicro toolchain timing', $guard); + self::assertIsInt($stepEnd); + $step = substr($platform, $guard, $stepEnd - $guard); + self::assertStringNotContainsString('if:', $step); + self::assertStringContainsString('release-toolchain-control', $step); + self::assertStringContainsString('probe-standalone-runtime.php', $step); + self::assertStringContainsString('verify-standalone-runtime.js', $step); + self::assertStringContainsString('--expected-version', $step); + self::assertStringContainsString('standalone-runtime-verification.json', $step); + } + } + public function test_release_phpmicro_cache_has_a_non_publishing_default_branch_warm_path(): void { $releaseWorkflow = self::readRepoFile('.github/workflows/release.yml'); From 44d6ef906d3a3db524ab683f608008b00baa0923 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:19:51 +0000 Subject: [PATCH 2/2] Report the running PHP version in runtime diagnostics --- src/Commands/RuntimeCheckCommand.php | 2 ++ tests/Commands/RuntimeCheckCommandTest.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Commands/RuntimeCheckCommand.php b/src/Commands/RuntimeCheckCommand.php index c92038f..47a3129 100644 --- a/src/Commands/RuntimeCheckCommand.php +++ b/src/Commands/RuntimeCheckCommand.php @@ -74,6 +74,8 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { + $output->writeln(sprintf('PHP runtime: %s (%s)', PHP_VERSION, PHP_SAPI)); + $missing = []; $requiredExtensions = $this->requiredExtensions(); diff --git a/tests/Commands/RuntimeCheckCommandTest.php b/tests/Commands/RuntimeCheckCommandTest.php index 2af8345..fbf3897 100644 --- a/tests/Commands/RuntimeCheckCommandTest.php +++ b/tests/Commands/RuntimeCheckCommandTest.php @@ -25,6 +25,7 @@ public function test_runtime_check_passes_when_required_extensions_are_loaded(): $tester = new CommandTester($command); self::assertSame(Command::SUCCESS, $tester->execute([])); + self::assertStringContainsString(sprintf('PHP runtime: %s (%s)', PHP_VERSION, PHP_SAPI), $tester->getDisplay()); self::assertStringContainsString('Runtime extensions OK', $tester->getDisplay()); } @@ -37,6 +38,7 @@ public function test_runtime_check_fails_when_required_extensions_are_missing(): $tester = new CommandTester($command); self::assertSame(Command::FAILURE, $tester->execute([])); + self::assertStringContainsString(sprintf('PHP runtime: %s (%s)', PHP_VERSION, PHP_SAPI), $tester->getDisplay()); self::assertStringContainsString('Missing required runtime extensions: curl', $tester->getDisplay()); }