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
16 changes: 10 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,9 @@ jobs:

build-phar:
name: Build PHAR (smoke test)
needs: [action-policy]
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
Expand All @@ -232,12 +233,15 @@ jobs:
clients: pip,python,composer,curl,git
- name: Disable APT download retries (CVE-2026-109518)
run: echo 'Acquire::Retries "0";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
- name: Check out the accepted PHP container build source
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
php-version: '8.4'
tools: composer:2.10.3, box:4.6.6
coverage: none
ini-values: phar.readonly=0
repository: durable-workflow/.github
ref: 18783be6bef737cc778e4c7282a8797e39daa61a
path: .central-apt-source
persist-credentials: false
- name: Build and prepare the pinned PHAR caller profile
run: bash "$GITHUB_WORKSPACE/scripts/ci/setup-php-phar-container.sh"
- run: php scripts/generate-build-info.php
- name: Verify actual Composer dependency trust
uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ unaffected. This binds the SPC component only. PHP, downloaded libraries, build
caches and each host platform still require their applicable toolchain
qualification.

The PHAR smoke caller has a separate [runtime profile](docker/php-ci/phar.ini)
and [launcher](scripts/ci/run-php-phar-container.sh). Its host controls and Box
artifact stay read-only. Only dependencies, build output, generated build
metadata and private runtime data are writable. The setup reuses the accepted
PHP image build source, then independently probes the PHAR settings and runs
the TLS and replacement controls. Source preparation alone does not qualify
the hosted caller or the separate reproducibility and legacy release paths.

Standalone release builds use the reviewed [dependency source policy](scripts/ci/standalone-dependencies.json).
The workflow verifies the OpenSSL archive before compilation and checks the
produced runtime after both cold builds and cache restores. Each release includes
Expand Down
4 changes: 4 additions & 0 deletions docker/php-ci/phar.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
date.timezone=UTC
memory_limit=-1
phar.readonly=0
opcache.enable_cli=0
11 changes: 11 additions & 0 deletions rust/parity/test_php_phar_container.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Discover PHAR launcher controls in the existing host-Python test lane."""
import importlib.util
from pathlib import Path


def load_tests(loader, _tests, _pattern):
source = Path(__file__).resolve().parents[2]/'scripts/ci/test_php_phar_container.py'
spec = importlib.util.spec_from_file_location('php_phar_controls', source)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return loader.loadTestsFromModule(module)
25 changes: 25 additions & 0 deletions scripts/ci/exec-box.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

final class BoxArtifactRefused extends RuntimeException {}

$box = $argv[1] ?? '';
$metadata = @lstat($box);
if (! is_file($box) || is_link($box)
|| $metadata === false || $metadata['uid'] !== posix_getuid()
|| $metadata['nlink'] !== 1 || ($metadata['mode'] & 07777) !== 0400
|| filesize($box) !== 2947031
|| hash_file('sha256', $box) !== 'aa0966319f709e74bf2bf1d58ddb987903ae4f6d0a9d335ec2261813c189f7fc') {
throw new BoxArtifactRefused('The read-only Box artifact differs from the reviewed release.');
}
array_splice($argv, 0, 2, [$box]);
$argc = count($argv);
$_SERVER['argv'] = $argv;
$_SERVER['argc'] = $argc;
// Box uses this entrypoint for its own child extract/info commands. Those
// children must reopen the verified read-only PHAR, not this guard script.
$_SERVER['SCRIPT_FILENAME'] = $box;
$_SERVER['SCRIPT_NAME'] = $box;
$_SERVER['PHP_SELF'] = $box;
require_once $box;
34 changes: 34 additions & 0 deletions scripts/ci/prepare-php-phar-container.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Prepare one inspected runtime for the PHAR caller. Setup owns image ancestry;
# this stage is also runnable against an already-built immutable local image.
set -euo pipefail
umask 077
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
image="${1:?Usage: prepare-php-phar-container.sh CONFIG_ID PRIVATE_STATE}"
state="${2:?Usage: prepare-php-phar-container.sh CONFIG_ID PRIVATE_STATE}"
[[ "$image" =~ ^sha256:[0-9a-f]{64}$ ]]
[[ -d "$state" && ! -L "$state" && -O "$state" && "$(realpath "$state")" = "$state" ]]
case "$state/" in "$root/"*) exit 1 ;; *) ;; esac
case "$root/" in "$state/"*) exit 1 ;; *) ;; esac
for directory in "$root/vendor" "$root/build" "$state/bin" "$state/artifacts" \
"$state/home" "$state/tmp" "$state/composer-home/cache"; do
[[ ! -L "$directory" ]]
mkdir -p "$directory"
[[ "$(realpath "$directory")" = "$directory" && -O "$directory" ]]
done
generated="$root/src/GeneratedBuildInfo.php"
[[ ! -L "$generated" ]]
if [[ ! -e "$generated" ]]; then (set -o noclobber; : > "$generated"); fi
[[ -f "$generated" && -O "$generated" && -n "$(find "$generated" -prune -type f -links 1)" ]]
bash "$root/scripts/ci/ensure-box.sh" 4.6.6 "$state/artifacts/box.phar"
box="$state/artifacts/box.phar"
[[ -f "$box" && ! -L "$box" && -O "$box" && -n "$(find "$box" -prune -type f -links 1)" ]]
chmod 0400 "$box"
for command in php composer box; do
ln -sf "$root/scripts/ci/run-php-phar-container.sh" "$state/bin/$command"
done
export CLI_PHP_CI_ROOT="$root" CLI_PHP_CI_IMAGE="$image" CLI_PHP_CI_STATE="$state"
"$state/bin/php" "$root/scripts/ci/probe-php-phar-runtime.php" 8.4
"$state/bin/box" --version
CLI_PHP_CI_TEST_IMAGE="$image" python3 -I -S -B "$root/scripts/ci/test-php-container-tls.py" -v
CLI_PHP_CI_TEST_IMAGE="$image" python3 -I -S -B "$root/scripts/ci/test_php_phar_container.py" -v
14 changes: 14 additions & 0 deletions scripts/ci/probe-php-phar-runtime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

final class PharRuntimeRefused extends RuntimeException {}

if (PHP_VERSION !== '8.4.27' || ini_get('phar.readonly') !== '0'
|| ini_get('memory_limit') !== '-1' || ini_get('opcache.enable_cli') !== '0' || date_default_timezone_get() !== 'UTC'
|| ! in_array('/usr/local/etc/php/conf.d/zz-cli-phar.ini', explode(",\n", trim((string) php_ini_scanned_files())), true)) {
throw new PharRuntimeRefused('The exact PHP PHAR profile must be active.');
}
require_once __DIR__.'/probe-php-test-runtime.php';
echo json_encode(['phar_readonly' => ini_get('phar.readonly'), 'memory_limit' => ini_get('memory_limit'),
'opcache_enable_cli' => ini_get('opcache.enable_cli'), 'timezone' => date_default_timezone_get()], JSON_THROW_ON_ERROR).PHP_EOL;
89 changes: 89 additions & 0 deletions scripts/ci/run-php-phar-container.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail

command="${0##*/}"
case "$command" in php|composer|box) ;; *) echo 'Invoke this PHAR launcher as php, composer or box.' >&2; exit 2 ;; esac
root="${CLI_PHP_CI_ROOT:?PHP CI repository root is required}"
state="${CLI_PHP_CI_STATE:?PHP CI state directory is required}"
image="${CLI_PHP_CI_IMAGE:?PHP CI image is required}"
if [[ ! "$image" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo 'PHP CI must use the inspected immutable image config ID.' >&2
exit 1
fi

options=(--rm --init --interactive --name "cli-php-ci-${command}-${BASHPID}"
--cpus 2 --memory 2g --pids-limit 512 --network host
--cap-drop ALL --security-opt no-new-privileges
--user "$(id -u):$(id -g)" --workdir "$PWD"
--env "HOME=$state/home"
--mount "type=bind,source=$root,target=$root,readonly"
--mount "type=bind,source=$state,target=$state,readonly")

# Keep host-executed launchers and controls read-only. Only dependency data,
# test output and private runtime caches can be changed by application code.
for directory in "$root/vendor" "$root/build" "$state/home" "$state/tmp" "$state/composer-home"; do
if [[ ! -d "$directory" || -L "$directory" || "$(realpath "$directory")" != "$directory" ]]; then
echo 'PHP CI writable directories must be existing real paths.' >&2
exit 1
fi
options+=(--mount "type=bind,source=$directory,target=$directory")
done
generated="$root/src/GeneratedBuildInfo.php"
[[ -f "$generated" && ! -L "$generated" && -O "$generated" && "$(realpath "$generated")" = "$generated" ]] || {
echo 'The generated build-info output must be a real owned file.' >&2; exit 1;
}
[[ -n "$(find "$generated" -prune -type f -links 1)" ]] || exit 1
options+=(--mount "type=bind,source=$generated,target=$generated"
--mount "type=bind,source=$root/docker/php-ci/phar.ini,target=/usr/local/etc/php/conf.d/zz-cli-phar.ini,readonly")

# The Composer verifier is a composite action outside the checkout.
if [[ -n "${GITHUB_ACTION_PATH:-}" ]]; then
options+=(--mount "type=bind,source=$GITHUB_ACTION_PATH,target=$GITHUB_ACTION_PATH,readonly")
fi
git_directory="$(git -C "$root" rev-parse --path-format=absolute --git-common-dir)"
if [[ "$git_directory" != "$root/"* ]]; then
options+=(--mount "type=bind,source=$git_directory,target=$git_directory,readonly")
fi

declare -A mounted=()
for variable in SSL_CERT_FILE CURL_CA_BUNDLE GIT_SSL_CAINFO COMPOSER_CAFILE \
REQUESTS_CA_BUNDLE PIP_CERT NPM_CONFIG_CAFILE CARGO_HTTP_CAINFO CARGO_HTTP_PROXY_CAINFO \
SSL_CERT_DIR GIT_SSL_CAPATH COMPOSER_CAPATH; do
if [[ -n "${!variable:-}" ]]; then
selected="${!variable}"
if [[ -z "${mounted[$selected]:-}" ]]; then
options+=(--mount "type=bind,source=$selected,target=$selected,readonly")
mounted[$selected]=1
fi
options+=(--env "$variable")
fi
done

# PHAR builds need no role credentials or PHP startup overrides.
while IFS= read -r variable; do
case "$variable" in
COMPOSER_HOME|COMPOSER_CACHE_DIR|COMPOSER_MAX_PARALLEL_*|COMPOSER_IPRESOLVE|\
DW_CI_CA_SHA256|GITHUB_ACTIONS|CI|TMPDIR|TZ|SOURCE_DATE_EPOCH|\
DW_CLI_VERSION|DW_CLI_COMMIT|DW_CLI_BUILD_DATE|GITHUB_SHA|GITHUB_REF_TYPE|GITHUB_REF_NAME)
options+=(--env "$variable") ;;
*) ;;
esac
done < <(compgen -e)

# The host verifier must inspect the actual container Composer PHAR.
arguments=()
for argument in "$@"; do
if [[ "$argument" == "$state/bin/composer" ]]; then
argument=/usr/local/bin/composer
fi
arguments+=("$argument")
done
if [[ "$command" = box ]]; then
artifact="$state/artifacts/box.phar"
[[ -f "$artifact" && ! -L "$artifact" && -O "$artifact" \
&& -n "$(find "$artifact" -prune -type f -links 1 -perm 0400)" ]] || {
echo 'Box must be a single-link owned read-only artifact.' >&2; exit 1;
}
exec docker run "${options[@]}" "$image" php "$root/scripts/ci/exec-box.php" "$state/artifacts/box.phar" "${arguments[@]}"
fi
exec docker run "${options[@]}" "$image" "$command" "${arguments[@]}"
34 changes: 34 additions & 0 deletions scripts/ci/setup-php-phar-container.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
metadata="$(mktemp -d "${RUNNER_TEMP:?RUNNER_TEMP is required}/cli-phar-setup.XXXXXX")"
trap 'rm -rf -- "$metadata"' EXIT
: > "$metadata/environment"
: > "$metadata/path"
# Reuse the accepted build/ancestry source without changing the admitted test
# profile. Its private outputs are parsed as data, never sourced as shell code.
GITHUB_ENV="$metadata/environment" GITHUB_PATH="$metadata/path" \
bash "$root/scripts/ci/setup-php-container.sh" 8.4
bindings="$(python3 -I -S -B - "$metadata/environment" "$metadata/path" "$root" <<'PY'
from pathlib import Path
import re,sys
lines = Path(sys.argv[1]).read_text().splitlines()
assert len(lines) == 6
values = dict(line.split('=', 1) for line in lines)
assert set(values) == {'CLI_PHP_CI_IMAGE','CLI_PHP_CI_ROOT','CLI_PHP_CI_STATE','COMPOSER_HOME','COMPOSER_CACHE_DIR','TMPDIR'}
assert values['CLI_PHP_CI_ROOT'] == sys.argv[3]
assert re.fullmatch(r'sha256:[0-9a-f]{64}', values['CLI_PHP_CI_IMAGE'])
state = values['CLI_PHP_CI_STATE']
assert values['COMPOSER_HOME'] == state+'/composer-home'
assert values['COMPOSER_CACHE_DIR'] == state+'/composer-home/cache'
assert values['TMPDIR'] == state+'/tmp'
assert Path(sys.argv[2]).read_text() == state+'/bin\n'
print(values['CLI_PHP_CI_IMAGE'])
print(state)
PY
)"
mapfile -t fields <<< "$bindings"
bash "$root/scripts/ci/prepare-php-phar-container.sh" "${fields[0]}" "${fields[1]}"
cat "$metadata/environment" >> "${GITHUB_ENV:?GITHUB_ENV is required}"
cat "$metadata/path" >> "${GITHUB_PATH:?GITHUB_PATH is required}"
Loading
Loading