Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
68 changes: 63 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@
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'
Expand All @@ -479,6 +479,8 @@
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
Expand Down Expand Up @@ -804,7 +806,24 @@
"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

Check failure

Code scanning / CodeQL

Cache Poisoning via execution of untrusted code High

Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code from
needs.resolve-release.outputs.commit
. (
schedule
).
Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code from
needs.resolve-release.outputs.commit
. (
workflow_dispatch
).
Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code from
needs.resolve-release.outputs.control_commit
. (
schedule
).
Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code from
needs.resolve-release.outputs.control_commit
. (
workflow_dispatch
).
if: always()
working-directory: build/.tools
shell: bash
Expand All @@ -820,6 +839,7 @@
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)"
Expand All @@ -831,7 +851,8 @@
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

Expand Down Expand Up @@ -903,6 +924,8 @@
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
Expand Down Expand Up @@ -988,7 +1011,7 @@
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'
Expand All @@ -1011,7 +1034,10 @@
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
Expand Down Expand Up @@ -1328,6 +1354,34 @@
"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
Expand All @@ -1341,6 +1395,7 @@
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
Expand All @@ -1351,7 +1406,8 @@
$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" }

Expand Down Expand Up @@ -1386,6 +1442,8 @@
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
Expand Down
9 changes: 9 additions & 0 deletions scripts/ci/probe-standalone-runtime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

echo json_encode([
'php_version' => PHP_VERSION,
'php_version_id' => PHP_VERSION_ID,
'sapi' => PHP_SAPI,
], JSON_THROW_ON_ERROR).PHP_EOL;
57 changes: 57 additions & 0 deletions scripts/ci/test-standalone-runtime.js
Original file line number Diff line number Diff line change
@@ -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);
});
65 changes: 65 additions & 0 deletions scripts/ci/verify-standalone-runtime.js
Original file line number Diff line number Diff line change
@@ -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 <PHP 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;
}
}
2 changes: 2 additions & 0 deletions src/Commands/RuntimeCheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
2 changes: 2 additions & 0 deletions tests/Commands/RuntimeCheckCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand All @@ -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());
}

Expand Down
33 changes: 31 additions & 2 deletions tests/ReleaseInstallerContractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
Loading