Skip to content

Commit 03ec028

Browse files
authored
Merge pull request #66 from durable-workflow/fix/standalone-php-floor-65-20261027
Build standalone CLI with verified PHP 8.4.27 runtime
2 parents 535cb6e + 44d6ef9 commit 03ec028

8 files changed

Lines changed: 232 additions & 7 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ jobs:
8282
node --check scripts/ci/release-version.js
8383
node --check scripts/ci/verify-cli-release-channel.js
8484
node --check scripts/ci/verify-cli-upgrade-result.js
85+
node --check scripts/ci/verify-standalone-runtime.js
86+
php -l scripts/ci/probe-standalone-runtime.php
8587
bash -n scripts/ci/verify-release-tag-source.sh
8688
bash -n scripts/ci/establish-native-prerequisites.sh
8789
bash -n scripts/ci/reconcile-release-metadata.sh
@@ -92,6 +94,7 @@ jobs:
9294
run: |
9395
python scripts/ci/test-native-prerequisites.py
9496
node --test scripts/ci/test-release-workflow-metadata.js
97+
node --test scripts/ci/test-standalone-runtime.js
9598
node --test scripts/ci/test-cli-release-channel.js
9699
node --test scripts/ci/test-cli-upgrade-result.js
97100
node --test scripts/ci/test-release-metadata.js

‎.github/workflows/release.yml‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ jobs:
456456
MUSL_SHA256: a9a118bbe84d8764da0ea0d28b3ab3fae8477fc7e4085d90102b8596fc7c75e4
457457
MUSL_VERSION: '1.2.5'
458458
NATIVE_PREREQUISITE_CACHE_SCHEMA: 'v1'
459-
PHP_VERSION: '8.4.23'
459+
PHP_VERSION: '8.4.27'
460460
PHPMICRO_CACHE_SCHEMA: 'v1'
461461
PHPMICRO_UNCACHED_BASELINE_SECONDS: '480'
462462
SPC_DOWNLOAD_OUTER_ATTEMPTS: '4'
@@ -479,6 +479,8 @@ jobs:
479479
sparse-checkout: |
480480
.github/workflows/release.yml
481481
scripts/ci/establish-native-prerequisites.sh
482+
scripts/ci/probe-standalone-runtime.php
483+
scripts/ci/verify-standalone-runtime.js
482484
sparse-checkout-cone-mode: false
483485

484486
- name: Download PHAR artifact
@@ -804,6 +806,23 @@ jobs:
804806
"sha256=$micro_sha256" \
805807
> phpmicro-cache.manifest
806808
809+
- name: Verify produced standalone PHP runtime
810+
id: runtime-policy
811+
timeout-minutes: 2
812+
shell: bash
813+
working-directory: build/.tools
814+
run: |
815+
set -euo pipefail
816+
policy="$GITHUB_WORKSPACE/release-toolchain-control/scripts/ci"
817+
./spc micro:combine "$policy/probe-standalone-runtime.php" \
818+
--output=standalone-runtime-probe
819+
chmod +x standalone-runtime-probe
820+
./standalone-runtime-probe > standalone-runtime.json
821+
node "$policy/verify-standalone-runtime.js" --expected-version "$PHP_VERSION" \
822+
< standalone-runtime.json > standalone-runtime-verification.json
823+
cat standalone-runtime-verification.json
824+
rm standalone-runtime-probe
825+
807826
- name: Record phpmicro toolchain timing
808827
if: always()
809828
working-directory: build/.tools
@@ -820,6 +839,7 @@ jobs:
820839
NATIVE_PREREQUISITE_IDENTITY: ${{ steps.native-prerequisites.outputs.identity }}
821840
NATIVE_PREREQUISITE_MODE: ${{ steps.native-prerequisites.outputs.mode }}
822841
NATIVE_PREREQUISITE_SOURCE: ${{ steps.native-prerequisites.outputs.source }}
842+
RUNTIME_POLICY_OUTCOME: ${{ steps.runtime-policy.outcome }}
823843
run: |
824844
set -euo pipefail
825845
finished_at="$(date +%s)"
@@ -831,7 +851,8 @@ jobs:
831851
cache_mode="cold-fallback"
832852
fi
833853
outcome="failure"
834-
if [ -s buildroot/bin/micro.sfx ] && [ -s phpmicro-cache.manifest ]; then
854+
if [ "$RUNTIME_POLICY_OUTCOME" = "success" ] &&
855+
[ -s buildroot/bin/micro.sfx ] && [ -s phpmicro-cache.manifest ]; then
835856
outcome="success"
836857
fi
837858
@@ -903,6 +924,8 @@ jobs:
903924
build/.tools/log/
904925
build/.tools/native-prerequisite-evidence.json
905926
build/.tools/phpmicro-timing.json
927+
build/.tools/standalone-runtime.json
928+
build/.tools/standalone-runtime-verification.json
906929
if-no-files-found: warn
907930

908931
- name: Combine PHAR into standalone binary
@@ -988,7 +1011,7 @@ jobs:
9881011
runs-on: windows-2022
9891012
env:
9901013
GITHUB_TOKEN: ${{ github.token }}
991-
PHP_VERSION_WINDOWS: '8.4.23'
1014+
PHP_VERSION_WINDOWS: '8.4.27'
9921015
PHPMICRO_CACHE_SCHEMA: 'v1'
9931016
PHPMICRO_UNCACHED_BASELINE_SECONDS: '480'
9941017
SPC_DOWNLOAD_OUTER_ATTEMPTS: '4'
@@ -1011,7 +1034,10 @@ jobs:
10111034
ref: ${{ needs.resolve-release.outputs.control_commit }}
10121035
path: release-toolchain-control
10131036
persist-credentials: false
1014-
sparse-checkout: .github/workflows/release.yml
1037+
sparse-checkout: |
1038+
.github/workflows/release.yml
1039+
scripts/ci/probe-standalone-runtime.php
1040+
scripts/ci/verify-standalone-runtime.js
10151041
sparse-checkout-cone-mode: false
10161042

10171043
- name: Download PHAR artifact
@@ -1328,6 +1354,34 @@ jobs:
13281354
"sha256=$microSha256"
13291355
) | Set-Content -Path phpmicro-cache.manifest -Encoding ascii
13301356
1357+
- name: Verify produced standalone PHP runtime
1358+
id: runtime-policy
1359+
timeout-minutes: 2
1360+
shell: pwsh
1361+
working-directory: build\.tools
1362+
run: |
1363+
$ErrorActionPreference = "Stop"
1364+
$policy = Join-Path $env:GITHUB_WORKSPACE "release-toolchain-control\scripts\ci"
1365+
$probe = Join-Path $policy "probe-standalone-runtime.php"
1366+
$verifier = Join-Path $policy "verify-standalone-runtime.js"
1367+
$runtimeProbe = Join-Path (Get-Location).Path "standalone-runtime-probe.exe"
1368+
.\spc.exe micro:combine $probe --output=$runtimeProbe
1369+
if ($LASTEXITCODE -ne 0) {
1370+
throw "Could not package the produced-runtime probe"
1371+
}
1372+
$evidence = & $runtimeProbe
1373+
if ($LASTEXITCODE -ne 0) {
1374+
throw "The produced-runtime probe failed"
1375+
}
1376+
$evidence | Set-Content -Path standalone-runtime.json -Encoding utf8
1377+
$evidence | node $verifier --expected-version "$env:PHP_VERSION_WINDOWS" `
1378+
> standalone-runtime-verification.json
1379+
if ($LASTEXITCODE -ne 0) {
1380+
throw "The produced runtime failed the PHP release policy"
1381+
}
1382+
Get-Content -Raw standalone-runtime-verification.json
1383+
Remove-Item -Force $runtimeProbe
1384+
13311385
- name: Record phpmicro toolchain timing
13321386
if: always()
13331387
shell: pwsh
@@ -1341,6 +1395,7 @@ jobs:
13411395
PLATFORM: windows-x86_64
13421396
RUN_PURPOSE: ${{ needs.resolve-release.outputs.cache_warm == 'true' && 'cache-warm' || 'release' }}
13431397
STARTED_AT: ${{ steps.phpmicro-timing-start.outputs.started_at }}
1398+
RUNTIME_POLICY_OUTCOME: ${{ steps.runtime-policy.outcome }}
13441399
run: |
13451400
$finishedAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
13461401
$elapsedSeconds = $finishedAt - [long]$env:STARTED_AT
@@ -1351,7 +1406,8 @@ jobs:
13511406
$cacheMode = "cold-fallback"
13521407
}
13531408
$outcome = if (
1354-
(Test-Path buildroot\bin\micro.sfx -PathType Leaf) `
1409+
$env:RUNTIME_POLICY_OUTCOME -eq "success" `
1410+
-and (Test-Path buildroot\bin\micro.sfx -PathType Leaf) `
13551411
-and (Test-Path phpmicro-cache.manifest -PathType Leaf)
13561412
) { "success" } else { "failure" }
13571413
@@ -1386,6 +1442,8 @@ jobs:
13861442
path: |
13871443
build/.tools/log/
13881444
build/.tools/phpmicro-timing.json
1445+
build/.tools/standalone-runtime.json
1446+
build/.tools/standalone-runtime-verification.json
13891447
if-no-files-found: warn
13901448

13911449
- name: Combine PHAR into standalone binary
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
echo json_encode([
6+
'php_version' => PHP_VERSION,
7+
'php_version_id' => PHP_VERSION_ID,
8+
'sapi' => PHP_SAPI,
9+
], JSON_THROW_ON_ERROR).PHP_EOL;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use strict';
2+
3+
const assert = require('node:assert/strict');
4+
const {spawnSync} = require('node:child_process');
5+
const path = require('node:path');
6+
const test = require('node:test');
7+
const {verifyStandaloneRuntime} = require('./verify-standalone-runtime');
8+
9+
const verifier = path.join(__dirname, 'verify-standalone-runtime.js');
10+
const fixed = {php_version: '8.4.27', php_version_id: 80427, sapi: 'micro'};
11+
12+
test('accepts the configured fixed micro runtime and later supported patch pins', () => {
13+
for (const patch of [27, 28]) {
14+
const version = `8.4.${patch}`;
15+
const result = verifyStandaloneRuntime({php_version: version, php_version_id: 80400 + patch, sapi: 'micro'}, version);
16+
assert.equal(result.verified, true);
17+
assert.equal(result.php_version, version);
18+
assert.equal(result.minimum_php_version, '8.4.27');
19+
}
20+
});
21+
22+
test('refuses an old runtime even when a caller supplies its matching old pin', () => {
23+
assert.throws(() => verifyStandaloneRuntime({php_version: '8.4.23', php_version_id: 80423, sapi: 'micro'}, '8.4.23'), /at least 8\.4\.27/);
24+
assert.throws(() => verifyStandaloneRuntime({php_version: '8.4.23', php_version_id: 80423, sapi: 'micro'}, '8.4.27'), /does not match/);
25+
});
26+
27+
test('refuses external PHP, prereleases and runtimes from a different cache identity', () => {
28+
assert.throws(() => verifyStandaloneRuntime({...fixed, sapi: 'cli'}, '8.4.27'), /produced micro SAPI/);
29+
assert.throws(() => verifyStandaloneRuntime({...fixed, php_version: '8.4.27RC1'}, '8.4.27'), /does not match/);
30+
assert.throws(() => verifyStandaloneRuntime({...fixed, php_version: '8.4.28', php_version_id: 80428}, '8.4.27'), /does not match/);
31+
});
32+
33+
test('refuses incomplete or contradictory runtime metadata', () => {
34+
for (const evidence of [null, [], {}, {...fixed, php_version_id: '80427'}, {...fixed, php_version_id: 80423}, {...fixed, php_version_id: 80427.5}]) {
35+
assert.throws(() => verifyStandaloneRuntime(evidence, '8.4.27'));
36+
}
37+
for (const version of ['8.5.0', '8.4.27-dev', 'v8.4.27', '8.4.027', null]) {
38+
assert.throws(() => verifyStandaloneRuntime(fixed, version), /stable 8\.4/);
39+
}
40+
});
41+
42+
test('the command fails closed on malformed, empty, noisy or oversized probe output', () => {
43+
for (const input of ['', '{', 'warning\n' + JSON.stringify(fixed), ' '.repeat(4097)]) {
44+
const result = spawnSync(process.execPath, [verifier, '--expected-version', '8.4.27'], {input, encoding: 'utf8'});
45+
assert.equal(result.status, 1);
46+
assert.equal(result.stdout, '');
47+
}
48+
});
49+
50+
test('the command emits a bounded verification receipt only for valid runtime evidence', () => {
51+
const result = spawnSync(process.execPath, [verifier, '--expected-version', '8.4.27'], {input: JSON.stringify(fixed), encoding: 'utf8'});
52+
assert.equal(result.status, 0);
53+
assert.equal(result.stderr, '');
54+
const receipt = JSON.parse(result.stdout);
55+
assert.equal(receipt.verified, true);
56+
assert.equal(receipt.php_version_id, 80427);
57+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
5+
const MINIMUM_PHP_VERSION = '8.4.27';
6+
const MINIMUM_PHP_VERSION_ID = 80427;
7+
8+
function verifyStandaloneRuntime(evidence, expectedVersion) {
9+
const match = typeof expectedVersion === 'string' && /^8\.4\.(0|[1-9]\d*)$/.exec(expectedVersion);
10+
if (!match) {
11+
throw new Error('standalone PHP pin must be a stable 8.4 patch version');
12+
}
13+
const expectedVersionId = 80400 + Number(match[1]);
14+
if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId < MINIMUM_PHP_VERSION_ID) {
15+
throw new Error(`standalone PHP pin must be at least ${MINIMUM_PHP_VERSION}`);
16+
}
17+
if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) {
18+
throw new Error('standalone runtime probe must return a JSON object');
19+
}
20+
if (evidence.sapi !== 'micro') {
21+
throw new Error('standalone runtime probe must execute in the produced micro SAPI');
22+
}
23+
if (evidence.php_version !== expectedVersion) {
24+
throw new Error('produced PHP runtime does not match the configured stable pin');
25+
}
26+
if (!Number.isInteger(evidence.php_version_id) || evidence.php_version_id !== expectedVersionId) {
27+
throw new Error('produced PHP runtime version ID does not match the configured pin');
28+
}
29+
30+
return Object.freeze({
31+
schema: 'durable-workflow.cli.standalone-runtime/v1',
32+
minimum_php_version: MINIMUM_PHP_VERSION,
33+
expected_php_version: expectedVersion,
34+
php_version: evidence.php_version,
35+
php_version_id: evidence.php_version_id,
36+
sapi: evidence.sapi,
37+
verified: true,
38+
});
39+
}
40+
41+
module.exports = {verifyStandaloneRuntime};
42+
43+
if (require.main === module) {
44+
try {
45+
const arguments_ = process.argv.slice(2);
46+
if (arguments_.length !== 2 || arguments_[0] !== '--expected-version') {
47+
throw new Error('usage: verify-standalone-runtime.js --expected-version <PHP version>');
48+
}
49+
const input = fs.readFileSync(0, 'utf8');
50+
if (Buffer.byteLength(input) > 4096) {
51+
throw new Error('standalone runtime probe output exceeds the size limit');
52+
}
53+
let evidence;
54+
try {
55+
evidence = JSON.parse(input);
56+
} catch {
57+
throw new Error('standalone runtime probe must return valid JSON');
58+
}
59+
const result = verifyStandaloneRuntime(evidence, arguments_[1]);
60+
process.stdout.write(`${JSON.stringify(result)}\n`);
61+
} catch (error) {
62+
process.stderr.write(`Standalone runtime verification failed: ${error.message}\n`);
63+
process.exitCode = 1;
64+
}
65+
}

‎src/Commands/RuntimeCheckCommand.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ protected function configure(): void
7474

7575
protected function execute(InputInterface $input, OutputInterface $output): int
7676
{
77+
$output->writeln(sprintf('PHP runtime: %s (%s)', PHP_VERSION, PHP_SAPI));
78+
7779
$missing = [];
7880
$requiredExtensions = $this->requiredExtensions();
7981

‎tests/Commands/RuntimeCheckCommandTest.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public function test_runtime_check_passes_when_required_extensions_are_loaded():
2525
$tester = new CommandTester($command);
2626

2727
self::assertSame(Command::SUCCESS, $tester->execute([]));
28+
self::assertStringContainsString(sprintf('PHP runtime: %s (%s)', PHP_VERSION, PHP_SAPI), $tester->getDisplay());
2829
self::assertStringContainsString('Runtime extensions OK', $tester->getDisplay());
2930
}
3031

@@ -37,6 +38,7 @@ public function test_runtime_check_fails_when_required_extensions_are_missing():
3738
$tester = new CommandTester($command);
3839

3940
self::assertSame(Command::FAILURE, $tester->execute([]));
41+
self::assertStringContainsString(sprintf('PHP runtime: %s (%s)', PHP_VERSION, PHP_SAPI), $tester->getDisplay());
4042
self::assertStringContainsString('Missing required runtime extensions: curl', $tester->getDisplay());
4143
}
4244

‎tests/ReleaseInstallerContractTest.php‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,8 @@ public function test_release_phpmicro_toolchain_is_pinned_verified_and_trust_sco
260260
$releaseWorkflow,
261261
'SPC_REVISION: 4318ef8fa32a02460ec1554746674a7bc42b49fa',
262262
));
263-
self::assertSame(1, substr_count($releaseWorkflow, "PHP_VERSION: '8.4.23'"));
264-
self::assertSame(1, substr_count($releaseWorkflow, "PHP_VERSION_WINDOWS: '8.4.23'"));
263+
self::assertSame(1, substr_count($releaseWorkflow, "PHP_VERSION: '8.4.27'"));
264+
self::assertSame(1, substr_count($releaseWorkflow, "PHP_VERSION_WINDOWS: '8.4.27'"));
265265
self::assertStringContainsString(
266266
'spc_sha256: 523ba4279c54c7a377156c0dd3a36adf92ee64b01e9a7f5e9e2ec084b8e458e5',
267267
$releaseWorkflow,
@@ -387,6 +387,35 @@ public function test_release_phpmicro_toolchain_is_pinned_verified_and_trust_sco
387387
self::assertStringContainsString('previous_uncached_baseline_seconds', $releaseWorkflow);
388388
}
389389

390+
public function test_release_checks_the_produced_runtime_before_combining_the_application(): void
391+
{
392+
$workflow = self::readRepoFile('.github/workflows/release.yml');
393+
$unixStart = strpos($workflow, "\n build-binary:");
394+
$windowsStart = strpos($workflow, "\n build-binary-windows:");
395+
self::assertIsInt($unixStart);
396+
self::assertIsInt($windowsStart);
397+
398+
foreach ([
399+
substr($workflow, $unixStart, $windowsStart - $unixStart),
400+
substr($workflow, $windowsStart),
401+
] as $platform) {
402+
$guard = strpos($platform, '- name: Verify produced standalone PHP runtime');
403+
$combine = strpos($platform, '- name: Combine PHAR into standalone binary');
404+
self::assertIsInt($guard);
405+
self::assertIsInt($combine);
406+
self::assertLessThan($combine, $guard);
407+
$stepEnd = strpos($platform, '- name: Record phpmicro toolchain timing', $guard);
408+
self::assertIsInt($stepEnd);
409+
$step = substr($platform, $guard, $stepEnd - $guard);
410+
self::assertStringNotContainsString('if:', $step);
411+
self::assertStringContainsString('release-toolchain-control', $step);
412+
self::assertStringContainsString('probe-standalone-runtime.php', $step);
413+
self::assertStringContainsString('verify-standalone-runtime.js', $step);
414+
self::assertStringContainsString('--expected-version', $step);
415+
self::assertStringContainsString('standalone-runtime-verification.json', $step);
416+
}
417+
}
418+
390419
public function test_release_phpmicro_cache_has_a_non_publishing_default_branch_warm_path(): void
391420
{
392421
$releaseWorkflow = self::readRepoFile('.github/workflows/release.yml');

0 commit comments

Comments
 (0)