diff --git a/docker/php-ci/README.md b/docker/php-ci/README.md index 7956c56..86cabd7 100644 --- a/docker/php-ci/README.md +++ b/docker/php-ci/README.md @@ -42,3 +42,16 @@ build identity fields needed by the source reference cross that boundary. Run `prepare-php-reference-container.sh CONFIG_ID PRIVATE_STATE` to check an already-inspected image, or the setup script to build the pinned runtime. The proposed caller fixture preserves the original vector, Cargo and parity commands. + +The two-build PHAR reproducibility caller reuses the reviewed PHAR profile +through a separate launcher. Its archived trees and comparison artifacts +live outside PHP's writable temporary data. The host prepares one selected +tree at a time; PHP sees that tree read-only with only vendor, build and the +generated build-info file writable. Other trees and the compared PHAR copy +are not mounted. The original build script's mutable Box cache path is +routed through the existing guard and read-only pinned Box artifact. + +The prepared caller is `scripts/ci/verify-php-reproducible-build.sh`; the +ordinary local script is unchanged. Each host-prepared tree receives a +read-only Box configuration with its timestamp set from `SOURCE_DATE_EPOCH`. +This pins PHAR metadata as well as the original input-file normalization. diff --git a/rust/parity/test_php_repro_container.py b/rust/parity/test_php_repro_container.py new file mode 100644 index 0000000..1b9aec7 --- /dev/null +++ b/rust/parity/test_php_repro_container.py @@ -0,0 +1,11 @@ +"""Discover Reproducibility 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_repro_container.py' + spec = importlib.util.spec_from_file_location('php_repro_controls', source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return loader.loadTestsFromModule(module) diff --git a/scripts/ci/prepare-php-repro-container.sh b/scripts/ci/prepare-php-repro-container.sh new file mode 100755 index 0000000..5198f6b --- /dev/null +++ b/scripts/ci/prepare-php-repro-container.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +image="${1:?Usage: prepare-php-repro-container.sh CONFIG_ID PRIVATE_STATE}" +state="${2:?Usage: prepare-php-repro-container.sh CONFIG_ID PRIVATE_STATE}" +[[ "$image" =~ ^sha256:[0-9a-f]{64}$ ]] +[[ -d "$state" && ! -L "$state" && -O "$state" && "$(realpath "$state")" = "$state" ]] +[[ -f "$state/artifacts/box.phar" && ! -L "$state/artifacts/box.phar" ]] +mkdir -p "$state/profile" +[[ ! -L "$state/profile" && "$(realpath "$state/profile")" = "$state/profile" ]] +for command in php composer box; do + ln -sf "$root/scripts/ci/run-php-phar-container.sh" "$state/profile/$command" + ln -sf "$root/scripts/ci/run-php-repro-container.sh" "$state/bin/$command" +done +export CLI_PHP_CI_ROOT="$root" CLI_PHP_CI_IMAGE="$image" CLI_PHP_CI_STATE="$state" CLI_PHP_CI_REPRO=1 +"$state/bin/php" "$root/scripts/ci/probe-php-phar-runtime.php" 8.4 +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_repro_container.py" -v diff --git a/scripts/ci/prepare-php-repro-tree.sh b/scripts/ci/prepare-php-repro-tree.sh new file mode 100755 index 0000000..fdc92e1 --- /dev/null +++ b/scripts/ci/prepare-php-repro-tree.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Only the host prepares archived controls. PHP receives a read-only tree. +set -euo pipefail +umask 077 +source_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +state="${CLI_PHP_CI_STATE:?PHP CI state is required}" +tree="${1:?Usage: prepare-php-repro-tree.sh ARCHIVED_TREE}" +[[ "${CLI_PHP_CI_REPRO:-}" = 1 && "${CLI_PHP_CI_ROOT:-}" = "$source_root" ]] +[[ -d "$state" && ! -L "$state" && -O "$state" && "$(realpath "$state")" = "$state" ]] +[[ -d "$tree" && ! -L "$tree" && -O "$tree" && "$(realpath "$tree")" = "$tree" ]] +case "$tree" in "${RUNNER_TEMP:?RUNNER_TEMP is required}"/dw-cli-repro.*/source.[12]) ;; *) exit 1 ;; esac +case "$tree/" in "$state/"*|"$source_root/"*) echo 'Reproducibility trees must stay outside runtime writable data and source.' >&2; exit 1 ;; *) ;; esac +for directory in "$tree/vendor" "$tree/build"; do + [[ ! -L "$directory" ]] + mkdir -p "$directory" + [[ "$(realpath "$directory")" = "$directory" && -O "$directory" ]] +done +generated="$tree/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)" ]] +# Input mtimes alone do not pin the archive metadata under the container +# runtime. Box supports one explicit timestamp for every PHAR entry. +python3 -I -S -B - "$tree/box.json.dist" "$tree/box.json" "${SOURCE_DATE_EPOCH:?SOURCE_DATE_EPOCH is required}" <<'PYCONFIG' +import json,os,re,sys +configuration=json.load(open(sys.argv[1])) +assert re.fullmatch(r'[0-9]{1,12}',sys.argv[3],flags=re.ASCII) +configuration['timestamp']='@'+sys.argv[3] +fd=os.open(sys.argv[2],os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o400) +with os.fdopen(fd,'w') as output: + json.dump(configuration,output,indent=4) + output.write('\n') +PYCONFIG +# The existing launcher queries the Git directory. This empty metadata is not +# a source revision and is excluded from the PHAR; build identity stays explicit. +[[ ! -e "$tree/.git" && ! -L "$tree/.git" ]] +git init --quiet "$tree" +metadata="$state/repro-tree" +if [[ -e "$metadata" || -L "$metadata" ]]; then + [[ -f "$metadata" && ! -L "$metadata" && -O "$metadata" \ + && -n "$(find "$metadata" -prune -type f -links 1 -perm 0400)" ]] +fi +held="$(mktemp "$state/.repro-tree.XXXXXX")" +trap 'rm -f -- "$held"' EXIT +printf '%s\n' "$tree" > "$held" +chmod 0400 "$held" +mv -f -- "$held" "$metadata" diff --git a/scripts/ci/run-php-repro-container.sh b/scripts/ci/run-php-repro-container.sh new file mode 100755 index 0000000..c05e8ad --- /dev/null +++ b/scripts/ci/run-php-repro-container.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +command="${0##*/}" +case "$command" in php|composer|box) ;; *) echo 'Invoke this reproducibility launcher as php, composer or box.' >&2; exit 2 ;; esac +source_root="${CLI_PHP_CI_ROOT:?PHP CI source root is required}" +state="${CLI_PHP_CI_STATE:?PHP CI state is required}" +[[ "${CLI_PHP_CI_REPRO:-}" = 1 ]] +root="$source_root" +metadata="$state/repro-tree" +if [[ -e "$metadata" || -L "$metadata" ]]; then + [[ -f "$metadata" && ! -L "$metadata" && -O "$metadata" \ + && -n "$(find "$metadata" -prune -type f -links 1 -perm 0400)" ]] || exit 1 + mapfile -t fields < "$metadata" + [[ "${#fields[@]}" = 1 ]] + tree="${fields[0]}" + case "$tree" in "${RUNNER_TEMP:?RUNNER_TEMP is required}"/dw-cli-repro.*/source.[12]) ;; *) exit 1 ;; esac + case "$PWD/" in "$tree/"*) + [[ -d "$tree" && ! -L "$tree" && -O "$tree" && "$(realpath "$tree")" = "$tree" ]] + case "$tree/" in "$state/"*|"$source_root/"*) exit 1 ;; *) ;; esac + root="$tree" ;; + *) ;; + esac +fi +# The helper's archive cache is writable build data. Execute only the pinned +# read-only Box artifact, including when the original build script invokes PHP. +arguments=("$@") +for argument in "$@"; do + if [[ "$argument" = "$root/build/.tools/box" ]]; then + [[ "$command" = php && "$#" -ge 3 && "$1" = -d && "$2" = phar.readonly=0 && "$3" = "$argument" ]] || exit 1 + arguments=(-d phar.readonly=0 "$root/scripts/ci/exec-box.php" "$state/artifacts/box.phar" "${@:4}") + break + fi +done +classic="$state/profile/$command" +[[ -L "$classic" && "$(realpath "$classic")" = "$source_root/scripts/ci/run-php-phar-container.sh" ]] +CLI_PHP_CI_ROOT="$root" exec "$classic" "${arguments[@]}" diff --git a/scripts/ci/setup-php-repro-container.sh b/scripts/ci/setup-php-repro-container.sh new file mode 100755 index 0000000..2fac9dd --- /dev/null +++ b/scripts/ci/setup-php-repro-container.sh @@ -0,0 +1,35 @@ +#!/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-repro-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-phar-container.sh" +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-repro-container.sh" "${fields[0]}" "${fields[1]}" +cat "$metadata/environment" >> "${GITHUB_ENV:?GITHUB_ENV is required}" +echo "CLI_PHP_CI_REPRO=1" >> "${GITHUB_ENV:?GITHUB_ENV is required}" +cat "$metadata/path" >> "${GITHUB_PATH:?GITHUB_PATH is required}" diff --git a/scripts/ci/test_php_repro_container.py b/scripts/ci/test_php_repro_container.py new file mode 100644 index 0000000..02322ad --- /dev/null +++ b/scripts/ci/test_php_repro_container.py @@ -0,0 +1,196 @@ +"""Two-tree reproducibility data and control boundaries.""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +def fixture(parent, predecessor=False): + root, state, runner = parent/'source', parent/'state', parent/'runner' + for name in ('vendor', 'build', 'src', 'scripts/ci', 'docker/php-ci'): + (root/name).mkdir(parents=True, exist_ok=True) + for name in ('bin', 'profile', 'artifacts', 'home', 'tmp', 'composer-home/cache'): + (state/name).mkdir(parents=True, exist_ok=True) + runner.mkdir() + for name in ('run-php-phar-container.sh', 'run-php-repro-container.sh', 'prepare-php-repro-tree.sh', 'exec-box.php'): + shutil.copyfile(ROOT/'scripts/ci'/name, root/'scripts/ci'/name) + (root/'scripts/ci'/name).chmod((ROOT/'scripts/ci'/name).stat().st_mode & 0o777) + (root/'docker/php-ci/phar.ini').write_bytes((ROOT/'docker/php-ci/phar.ini').read_bytes()) + (root/'src/GeneratedBuildInfo.php').touch() + (root/'box.json.dist').write_bytes((ROOT/'box.json.dist').read_bytes()) + (root/'scripts/build.sh').write_text('synthetic archived control') + subprocess.run(['git', 'init', '--quiet', str(root)], check=True) + attempt = runner/'dw-cli-repro.fixture' + tree = attempt/'source.1' + shutil.copytree(root, tree, ignore=shutil.ignore_patterns('.git')) + (attempt/'dw.phar.1').write_text('first comparison artifact') + (attempt/'source.2').mkdir() + (state/'artifacts/box.phar').write_text('synthetic artifact') + (state/'artifacts/box.phar').chmod(0o400) + for name in ('php', 'composer', 'box'): + (state/'bin'/name).symlink_to(root/'scripts/ci/run-php-repro-container.sh') + (state/'profile'/name).symlink_to(root/'scripts/ci/run-php-phar-container.sh') + environment = {'PATH':os.defpath, 'CLI_PHP_CI_ROOT':str(root), 'CLI_PHP_CI_STATE':str(state), + 'CLI_PHP_CI_IMAGE':'sha256:'+'f'*64, 'CLI_PHP_CI_REPRO':'1', 'RUNNER_TEMP':str(runner), + 'TMPDIR':str(state/'tmp'), 'CLI_TEST_ATTEMPT':str(attempt), 'SOURCE_DATE_EPOCH':'1234567890'} + subprocess.run(['bash', str(root/'scripts/ci/prepare-php-repro-tree.sh'), str(tree)], + env=environment, check=True, capture_output=True, timeout=10) + if predecessor: + classic = root/'scripts/ci/run-php-phar-container.sh' + source = classic.read_text().replace('target=$root,readonly', 'target=$root').replace('target=$state,readonly', 'target=$state') + source = source.replace('declare -A mounted=()', + 'options+=(--mount "type=bind,source=$CLI_TEST_ATTEMPT,target=$CLI_TEST_ATTEMPT")\ndeclare -A mounted=()') + classic.write_text(source) + return root, state, tree, environment + + +class ReproLauncherTests(unittest.TestCase): + def invoke(self, arguments=(), mutate=None): + with tempfile.TemporaryDirectory() as directory: + root, state, tree, env = fixture(Path(directory)) + docker = state/'bin/docker' + docker.write_text('#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps(sys.argv[1:]))\n') + docker.chmod(0o755) + env['PATH'] = str(state/'bin')+os.pathsep+os.defpath + if mutate: + mutate(root, state, tree, env) + args = [str(tree/'build/.tools/box') if x == '{box}' else x for x in arguments] + result = subprocess.run([str(state/'bin/php'), *args], cwd=tree, env=env, + capture_output=True, text=True, timeout=10) + return result, str(root), str(state), str(tree) + + def test_selected_tree_is_readonly_and_parent_is_not_exposed(self): + result, _root, state, tree = self.invoke() + self.assertEqual(0, result.returncode, result.stderr) + options = json.loads(result.stdout) + mounts = [options[i+1] for i, item in enumerate(options[:-1]) if item == '--mount'] + self.assertIn(f'type=bind,source={tree},target={tree},readonly', mounts) + self.assertIn(f'type=bind,source={state},target={state},readonly', mounts) + parent = str(Path(tree).parent) + self.assertFalse(any(f'source={parent},' in item for item in mounts)) + for name in ('vendor', 'build', 'src/GeneratedBuildInfo.php'): + self.assertIn(f'type=bind,source={tree}/{name},target={tree}/{name}', mounts) + + def test_mutable_box_cache_is_replaced_with_the_readonly_artifact_guard(self): + result, _root, state, tree = self.invoke(('-d', 'phar.readonly=0', '{box}', 'compile', '--no-parallel')) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(['php', '-d', 'phar.readonly=0', tree+'/scripts/ci/exec-box.php', state+'/artifacts/box.phar', 'compile', '--no-parallel'], json.loads(result.stdout)[-7:]) + result, _, _, _ = self.invoke(('{box}', '--version')) + self.assertNotEqual(0, result.returncode) + self.assertEqual('', result.stdout) + + def test_linked_or_malformed_control_metadata_refuses_before_docker(self): + def linked(root, state, _tree, _env): + (state/'repro-tree').unlink() + (state/'repro-tree').symlink_to(root/'scripts/build.sh') + def hardlinked(root, state, _tree, _env): + os.link(state/'repro-tree', root/'alias') + def multiple(_root, state, _tree, _env): + path=state/'repro-tree';path.chmod(0o600);path.write_text('one\ntwo\n');path.chmod(0o400) + def profile(_root, _state, _tree, env): + env['CLI_PHP_CI_REPRO']='unreviewed' + for mutation in (linked, hardlinked, multiple, profile): + result, _, _, _ = self.invoke(mutate=mutation) + self.assertNotEqual(0, result.returncode) + self.assertEqual('', result.stdout) + + def test_prepared_box_config_pins_time_and_preserves_source_settings(self): + with tempfile.TemporaryDirectory() as directory: + root,_state,tree,_env=fixture(Path(directory)) + source=json.loads((root/'box.json.dist').read_text()) + prepared=json.loads((tree/'box.json').read_text()) + self.assertEqual('@1234567890',prepared.pop('timestamp')) + self.assertEqual(source,prepared) + self.assertEqual(0o400,(tree/'box.json').stat().st_mode & 0o777) + + def test_tree_preparation_refuses_linked_or_shared_runtime_storage(self): + with tempfile.TemporaryDirectory() as directory: + root, state, tree, env = fixture(Path(directory)) + shared = state/'tmp/dw-cli-repro.shared/source.1' + shared.mkdir(parents=True) + env['RUNNER_TEMP']=str(state/'tmp') + command=['bash',str(root/'scripts/ci/prepare-php-repro-tree.sh'),str(shared)] + self.assertNotEqual(0, subprocess.run(command,env=env,capture_output=True,timeout=10).returncode) + tree.rename(tree.parent/'hidden') + tree.symlink_to(tree.parent/'hidden') + env['RUNNER_TEMP']=str(tree.parent.parent) + command[-1]=str(tree) + self.assertNotEqual(0, subprocess.run(command,env=env,capture_output=True,timeout=10).returncode) + + +@unittest.skipUnless(os.environ.get('CLI_PHP_CI_TEST_IMAGE'), 'requires inspected PHP image') +class ReproRuntimeBoundaryTests(unittest.TestCase): + def test_archive_controls_wrappers_metadata_and_comparison_artifact_are_protected(self): + for predecessor in (True, False): + for attack in ('control', 'wrapper', 'metadata', 'comparison'): + with self.subTest(predecessor=predecessor,attack=attack), tempfile.TemporaryDirectory( + dir=Path(os.environ['CLI_PHP_CI_STATE'])/'tmp') as directory: + root,state,tree,env=fixture(Path(directory),predecessor) + env['CLI_PHP_CI_IMAGE']=os.environ['CLI_PHP_CI_TEST_IMAGE'] + before=hashlib.sha256((tree/'scripts/build.sh').read_bytes()).hexdigest() + code=r''' +[$unused,$attack,$root,$state,$tree]=$argv; +$target=['control'=>$tree.'/scripts/build.sh','wrapper'=>$state.'/bin/php', + 'metadata'=>$state.'/repro-tree','comparison'=>dirname($tree).'/dw.phar.1'][$attack]; +if ($attack==='wrapper') { @unlink($target); } +if ($attack==='metadata') { @chmod($target,0600); } +$changed=@file_put_contents($target,'replacement')!==false; +$writable=true; +foreach ([$tree.'/vendor/output',$tree.'/build/output',$tree.'/src/GeneratedBuildInfo.php', + $state.'/tmp/output',$state.'/home/output',$state.'/composer-home/output'] as $path) { + $writable=$writable && file_put_contents($path,'synthetic output')!==false; +} +echo json_encode(['changed'=>$changed,'writable'=>$writable]); +''' + command=[str(state/'bin/php'),'-r',code,'--',attack,str(root),str(state),str(tree)] + result=subprocess.run(command,cwd=tree,env=env,capture_output=True,text=True,timeout=30) + self.assertEqual(0,result.returncode,result.stderr) + observation=json.loads(result.stdout) + self.assertEqual(predecessor,observation['changed']) + self.assertTrue(observation['writable']) + if not predecessor: + following=subprocess.run([str(state/'bin/php'),'-r','echo "CLI_REPRO_REAL_PHP";'], + cwd=tree,env=env,capture_output=True,text=True,timeout=30) + self.assertEqual(0,following.returncode,following.stderr) + self.assertEqual('CLI_REPRO_REAL_PHP',following.stdout) + self.assertEqual(before,hashlib.sha256((tree/'scripts/build.sh').read_bytes()).hexdigest()) + self.assertEqual('first comparison artifact',(tree.parent/'dw.phar.1').read_text()) + + + def test_mutated_archive_box_cache_cannot_replace_the_pinned_executable(self): + for predecessor in (True, False): + with self.subTest(predecessor=predecessor), tempfile.TemporaryDirectory( + dir=Path(os.environ['CLI_PHP_CI_STATE'])/'tmp') as directory: + root,state,tree,env=fixture(Path(directory)) + env['CLI_PHP_CI_IMAGE']=os.environ['CLI_PHP_CI_TEST_IMAGE'] + artifact=state/'artifacts/box.phar' + artifact.chmod(0o600) + artifact.write_bytes((Path(os.environ['CLI_PHP_CI_STATE'])/'artifacts/box.phar').read_bytes()) + artifact.chmod(0o400) + cache=tree/'build/.tools/box' + cache.parent.mkdir() + cache.write_text('&2; exit 1; +} +# Keep archive controls and compared artifacts outside PHP writable data. +ATTEMPT_DIR="$(mktemp -d "${RUNNER_TEMP:?RUNNER_TEMP is required}/dw-cli-repro.XXXXXX")" +FIRST_TREE="$ATTEMPT_DIR/source.1" +SECOND_TREE="$ATTEMPT_DIR/source.2" + +cleanup() { + rm -rf -- "$ATTEMPT_DIR" +} + +trap cleanup EXIT +trap 'exit 1' HUP INT TERM + +err() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + err "sha256sum or shasum is required" + fi +} + +if ! command -v git >/dev/null 2>&1; then + err "git is required to create independent source trees" +fi +if ! command -v tar >/dev/null 2>&1; then + err "tar is required to create independent source trees" +fi + +SOURCE_COMMIT="$(git -C "$ROOT" rev-parse --verify 'HEAD^{commit}' 2>/dev/null || true)" +if [[ -z "$SOURCE_COMMIT" ]]; then + err "git HEAD is not available" +fi + +if [[ -z "${SOURCE_DATE_EPOCH:-}" ]]; then + SOURCE_DATE_EPOCH="$(git -C "$ROOT" log -1 --pretty=%ct "$SOURCE_COMMIT" 2>/dev/null || true)" + if [[ -z "${SOURCE_DATE_EPOCH:-}" ]]; then + err "SOURCE_DATE_EPOCH is unset and git HEAD timestamp is not available" + fi + export SOURCE_DATE_EPOCH +fi + +if [[ -z "${DW_CLI_COMMIT:-}" ]]; then + export DW_CLI_COMMIT="$SOURCE_COMMIT" +fi + +if [[ -z "${DW_CLI_VERSION:-}" && "${GITHUB_REF_TYPE:-}" != "tag" ]]; then + source_tag="$(git -C "$ROOT" describe --tags --exact-match "$SOURCE_COMMIT" 2>/dev/null || true)" + if [[ -n "$source_tag" ]]; then + export DW_CLI_VERSION="${source_tag#v}" + fi +fi + +echo ">> Reproducible-build check: SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" + +create_source_tree() { + local tree="$1" + + mkdir -p "$tree" + git -C "$ROOT" archive --format=tar "$SOURCE_COMMIT" | tar -xf - -C "$tree" + if [[ "${CLI_PHP_CI_REPRO:-}" = 1 ]]; then + bash "$ROOT/scripts/ci/prepare-php-repro-tree.sh" "$tree" + fi +} + +build_attempt() { + local label="$1" + local tree="$2" + local output="$3" + + echo ">> Preparing $label build in an independent source tree" + create_source_tree "$tree" + ( + cd "$tree" + scripts/build.sh phar + ) + [[ -s "$tree/build/dw.phar" ]] || err "$label build did not produce $tree/build/dw.phar" + cp "$tree/build/dw.phar" "$output" +} + +first="$ATTEMPT_DIR/dw.phar.1" +build_attempt "first" "$FIRST_TREE" "$first" +sha1="$(sha256_of "$first")" +echo ">> First build sha256: $sha1" + +second="$ATTEMPT_DIR/dw.phar.2" +build_attempt "second" "$SECOND_TREE" "$second" +sha2="$(sha256_of "$second")" +echo ">> Second build sha256: $sha2" + +if [[ "$sha1" != "$sha2" ]]; then + if command -v cmp >/dev/null 2>&1; then + cmp -l "$first" "$second" | head -20 >&2 || true + fi + err "PHAR builds are not bit-identical (sha256 differs). See docs/distribution.md for the reproducible-build contract." +fi + +printf '\nReproducible build verified: dw.phar sha256=%s\n' "$sha1" diff --git a/tests/fixtures/php-repro-workflow.yml b/tests/fixtures/php-repro-workflow.yml new file mode 100644 index 0000000..c41264d --- /dev/null +++ b/tests/fixtures/php-repro-workflow.yml @@ -0,0 +1,35 @@ +# Proposed caller. Activate only after exact central source admission. +jobs: + reproducible-build: + name: Reproducible build (PHAR is bit-identical across rebuilds) + needs: [action-policy] + if: ${{ github.server_url == 'https://github.com' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - name: Bootstrap verified dependency trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + 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 + - name: Check out the accepted PHP container build source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false + - name: Build and prepare the pinned PHP reproducibility profile + run: bash "$GITHUB_WORKSPACE/scripts/ci/setup-php-repro-container.sh" + - name: Verify actual Composer dependency trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + - name: Verify dw.phar reproduces bit-for-bit + run: scripts/ci/verify-php-reproducible-build.sh