diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 464ff418..409e6cf6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -48,8 +48,14 @@ concurrency: jobs: build: runs-on: ubuntu-24.04 + timeout-minutes: 25 env: DOCS_SOURCE_DIRECTORY: ${{ github.workspace }} + GITHUB_TOKEN: '' + GH_TOKEN: '' + GITHUB_ENTERPRISE_TOKEN: '' + GH_ENTERPRISE_TOKEN: '' + COMPOSER_AUTH: '' outputs: stable: ${{ steps.release-channel.outputs.stable }} steps: @@ -79,17 +85,63 @@ jobs: with: ref: ${{ inputs.stable_source || (inputs.stable_tag != '' && format('refs/tags/{0}', inputs.stable_tag)) || github.sha }} fetch-depth: 0 - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + persist-credentials: false + - name: Check out the accepted PHP terminal control source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - python-version: '3.12' + repository: durable-workflow/.github + ref: ed15002a156725523b23eefb935a243cb1f84952 + path: .php-setup-control + persist-credentials: false + - name: Select the trusted system Python + env: + LD_PRELOAD: '' + LD_LIBRARY_PATH: '' + LD_AUDIT: '' + run: | + /usr/bin/python3 -I -S -B - <<'PYTHON' + import os,pathlib,sys + if sys.version_info[:2] != (3,12) or os.environ.get('pythonLocation'): + raise ValueError('Expected the Ubuntu 24 system Python without toolcache selection') + selected=pathlib.Path('/usr/bin/python').resolve(strict=True) + if (selected != pathlib.Path(sys.executable).resolve(strict=True) + or selected != pathlib.Path('/usr/bin/python3.12')): + raise ValueError('System Python aliases do not share the exact reviewed origin') + with open(os.environ['GITHUB_PATH'],'a') as output: + output.write('/usr/bin\n') + PYTHON + - name: Create caller-owned Composer home, cache and scratch + id: composer_state + run: | + /usr/bin/python3 -I -S -B - <<'PY' + import json,os,pathlib,shutil,stat,tempfile + root=pathlib.Path(tempfile.mkdtemp(prefix='php-docs-composer-',dir=os.environ['RUNNER_TEMP'])) + try: + for name in ('home','cache','tmp'): + (root/name).mkdir(mode=0o700) + metadata=root.lstat() + identity=[metadata.st_dev,metadata.st_ino,metadata.st_uid,stat.S_IMODE(metadata.st_mode)] + with open(os.environ['GITHUB_ENV'],'a') as output: + for name,child in (('COMPOSER_HOME','home'),('COMPOSER_CACHE_DIR','cache'),('TMPDIR','tmp')): + output.write(f'{name}={root/child}\n') + with open(os.environ['GITHUB_OUTPUT'],'a') as output: + output.write(f'directory={root}\nidentity={json.dumps(identity)}\n') + except BaseException: + shutil.rmtree(root) + raise + PY - name: Select authenticated dependency trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + id: pip_trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust with: clients: pip,python,composer,curl,git,npm - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + - name: Set up the exact accepted initial PHP profile + id: php_setup + uses: durable-workflow/.github/.github/actions/setup-php-linux@ed15002a156725523b23eefb935a243cb1f84952 # bound-linux-php with: php-version: '8.3' - tools: composer:2.10.3 + ini-file: production + tools: composer:2.10.3,phpunit:11.5.57 coverage: none - name: Select the temporary tooling directory run: echo "DOCS_TOOLING_DIRECTORY=$RUNNER_TEMP/docs-tooling" >> "$GITHUB_ENV" @@ -119,7 +171,9 @@ jobs: with: node-version: '24' - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + working-directory: ${{ env.DOCS_TOOLING_DIRECTORY }} - run: composer --working-dir="$DOCS_TOOLING_DIRECTORY" install --no-interaction --prefer-dist - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" ci --ignore-scripts - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" audit --audit-level=moderate @@ -138,7 +192,9 @@ jobs: --root-dir . README.md docs/*.md - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + working-directory: ${{ env.DOCS_TOOLING_DIRECTORY }} - name: Build authored portal and generated API reference env: DOCS_REQUIRE_ANALYTICS_TOKEN: 'true' @@ -158,7 +214,7 @@ jobs: --offline --root-dir build/site build/site - - run: '"$DOCS_TOOLING_DIRECTORY/node_modules/.bin/playwright" install --with-deps chromium' + - run: '"$DOCS_TOOLING_DIRECTORY/node_modules/.bin/playwright" install chromium' - name: Test missing-resource browser evidence run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-browser-failures - name: Check portal and API layouts in Chromium @@ -178,6 +234,79 @@ jobs: run: | test "$(git -C "$DOCS_TOOLING_DIRECTORY" rev-parse HEAD)" = "$GITHUB_WORKFLOW_SHA" python "$DOCS_TOOLING_DIRECTORY/scripts/bind-docs-artifact.py" "$EXPECTED_SOURCE_REVISION" + - name: Revalidate PHP after product commands and clean owned state + if: ${{ always() && steps.composer_state.outcome != 'skipped' }} + env: + SETUP_RECEIPT: ${{ steps.php_setup.outputs.bootstrap-receipt }} + PIP_RECEIPT: ${{ steps.pip_trust.outputs.pip-state }} + PIP_CREATED: ${{ steps.pip_trust.outputs.pip-created }} + PIP_ROOT_IDENTITY: ${{ steps.pip_trust.outputs.pip-root-identity }} + PIP_RECEIPT_IDENTITY: ${{ steps.pip_trust.outputs.pip-receipt-identity }} + COMPOSER_DIRECTORY: ${{ steps.composer_state.outputs.directory }} + COMPOSER_DIRECTORY_IDENTITY: ${{ steps.composer_state.outputs.identity }} + PRODUCT_STATUS: ${{ job.status }} + LD_PRELOAD: '' + LD_LIBRARY_PATH: '' + LD_AUDIT: '' + run: | + /usr/bin/python3 -I -S -B - <<'PY' + import json,os,pathlib,shutil,stat,subprocess,sys,tempfile + runner_temp=pathlib.Path(os.environ['RUNNER_TEMP']) + composer_text=os.environ.get('COMPOSER_DIRECTORY','') + composer_root=pathlib.Path(composer_text) if composer_text else None + composer_identity=os.environ.get('COMPOSER_DIRECTORY_IDENTITY','') + try: + control_source='ed15002a156725523b23eefb935a243cb1f84952' + control_directory=pathlib.Path.cwd()/'.php-setup-control' + def control_git(*arguments): + return subprocess.check_output(['/usr/bin/git','-C',str(control_directory),*arguments],text=True).strip() + if control_git('rev-parse','HEAD')!=control_source: + raise ValueError('PHP terminal control source differs from its immutable binding') + subprocess.run(['/usr/bin/git','-C',str(control_directory),'diff','--exit-code',control_source,'--'],check=True,stdout=subprocess.DEVNULL) + if control_git('ls-files','--others'): + raise ValueError('PHP terminal control source contains untracked files') + sys.path.insert(0,str(control_directory)) + from scripts import php_linux_bootstrap as bootstrap + from scripts import php_linux_qualification as qualification + pip_state={key:os.environ.get(name,'') for key,name in ( + ('receipt','PIP_RECEIPT'),('created','PIP_CREATED'), + ('root_identity','PIP_ROOT_IDENTITY'),('receipt_identity','PIP_RECEIPT_IDENTITY'))} + receipt_text=os.environ.get('SETUP_RECEIPT','') + receipt=pathlib.Path(receipt_text) if receipt_text else None + product_status=os.environ['PRODUCT_STATUS'] + context={key:os.environ[key] for key in ('GITHUB_REPOSITORY','GITHUB_WORKFLOW_SHA', + 'GITHUB_SHA','GITHUB_REF','GITHUB_EVENT_NAME','GITHUB_RUN_ID','GITHUB_RUN_ATTEMPT')} + with tempfile.TemporaryDirectory(prefix='php-docs-terminal-',dir=runner_temp) as directory: + private=pathlib.Path(directory) + (private/'home').mkdir(mode=0o700) + (private/'tmp').mkdir(mode=0o700) + closed=bootstrap.clean_environment(dict(os.environ),str(private/'home')) + os.environ.clear() + os.environ.update(closed) + def publish(evidence): + evidence.update(source=control_source,php_helper_source=control_source, + pip_predecessor_source='242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5',control=context, + caller_product_status=product_status) + text=json.dumps(evidence,sort_keys=True) + if len(text.encode())>65536: + raise ValueError('PHP terminal evidence exceeds its fixed bound') + print(text) + with open(os.environ['GITHUB_STEP_SUMMARY'],'a') as output: + output.write('PHP docs post-product terminal qualification\n\n'+text+'\n') + qualification.qualify(receipt,runner_temp,closed,publish,pip_state=pip_state) + finally: + if composer_root is not None: + metadata=composer_root.lstat() + actual=[metadata.st_dev,metadata.st_ino,metadata.st_uid,stat.S_IMODE(metadata.st_mode)] + if (composer_root.parent!=runner_temp or not composer_root.name.startswith('php-docs-composer-') + or not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid!=os.getuid() + or actual!=json.loads(composer_identity)): + raise ValueError('Exact caller-owned Composer state changed; cleanup refused') + shutil.rmtree(composer_root) + if os.path.lexists(composer_root): + raise ValueError('Caller-owned Composer state remains after cleanup') + print(json.dumps({'composer_state_cleanup':'pass'})) + PY - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 if: github.server_url == 'https://github.com' with: diff --git a/scripts/test-docs-release-channel.py b/scripts/test-docs-release-channel.py index faa89944..78532ba7 100644 --- a/scripts/test-docs-release-channel.py +++ b/scripts/test-docs-release-channel.py @@ -4,12 +4,17 @@ from __future__ import annotations import json +import io import os import subprocess import sys import tempfile +import textwrap +import types import unittest +from contextlib import redirect_stdout from pathlib import Path +from unittest.mock import patch GUARD = Path(__file__).with_name("check-docs-release-channel.py").resolve() @@ -242,5 +247,135 @@ def testInvalidExplicitInputsFailClosed(self) -> None: self.channel(None, **inputs) +class DocumentationSystemPythonSelectionTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + workflow = Path(__file__).resolve().parents[1] / ".github/workflows/docs.yml" + step = workflow.read_text().split(" - name: Select the trusted system Python\n", 1)[1] + cls.script = textwrap.dedent(step.split("<<'PYTHON'\n", 1)[1].split(" PYTHON\n", 1)[0]) + + def selection(self, *, version=(3, 12, 3), location="", foreign_alias=False): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "github-path" + output.touch(mode=0o600) + def canonical(path, strict=False): + if foreign_alias and str(path) == "/usr/bin/python": + return Path("/opt/shared/python") + return Path("/usr/bin/python3.12") + with patch.dict(os.environ, {"GITHUB_PATH": str(output), "pythonLocation": location}, clear=True), \ + patch.object(sys, "version_info", version), \ + patch.object(sys, "executable", "/usr/bin/python3"), \ + patch.object(Path, "resolve", autospec=True, side_effect=canonical): + try: + exec(compile(self.script, "actual-docs-system-python-step", "exec"), {}) + except ValueError as error: + return output.read_text(), str(error) + return output.read_text(), None + + def test_exact_system_alias_is_selected(self): + output, error = self.selection() + self.assertEqual("/usr/bin\n", output) + self.assertIsNone(error) + + def test_toolcache_selection_refuses_before_path_change(self): + output, error = self.selection(location="/opt/hostedtoolcache/Python/3.12.16/x64") + self.assertEqual("", output) + self.assertIn("without toolcache", error) + + def test_foreign_alias_refuses_before_path_change(self): + output, error = self.selection(foreign_alias=True) + self.assertEqual("", output) + self.assertIn("exact reviewed origin", error) + + def test_other_version_refuses_before_path_change(self): + output, error = self.selection(version=(3, 13, 0)) + self.assertEqual("", output) + self.assertIn("Ubuntu 24 system Python", error) + + +class DocumentationTerminalCollectorTest(unittest.TestCase): + """Caller orchestration only; shared setup/pip guards are not implemented here.""" + + @classmethod + def setUpClass(cls): + workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/docs.yml").read_text() + step = workflow.split(" - name: Revalidate PHP after product commands and clean owned state\n", 1)[1] + cls.environment = step.split(" run: |\n", 1)[0] + cls.script = textwrap.dedent(step.split("<<'PY'\n", 1)[1].split(" PY\n", 1)[0]) + + def collect(self, product_status="success", refusal=False, oversized=False): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + scratch = root / "runner" + scratch.mkdir() + composer = scratch / "php-docs-composer-fixture" + composer.mkdir(mode=0o700) + info = composer.lstat() + identity = [info.st_dev, info.st_ino, info.st_uid, info.st_mode & 0o7777] + environment = {"RUNNER_TEMP": str(scratch), "COMPOSER_DIRECTORY": str(composer), + "COMPOSER_DIRECTORY_IDENTITY": json.dumps(identity), "PRODUCT_STATUS": product_status, + "GITHUB_STEP_SUMMARY": str(root / "summary"), "SETUP_RECEIPT": str(root / "setup"), + "PIP_RECEIPT": str(root / "pip"), "PIP_CREATED": "true", + "PIP_ROOT_IDENTITY": "fixture-root", "PIP_RECEIPT_IDENTITY": "fixture-receipt"} + environment.update({key: "fixture" for key in ("GITHUB_REPOSITORY", "GITHUB_WORKFLOW_SHA", "GITHUB_SHA", + "GITHUB_REF", "GITHUB_EVENT_NAME", "GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT")}) + package = types.ModuleType("scripts") + package.php_linux_bootstrap = types.ModuleType("bootstrap") + package.php_linux_qualification = types.ModuleType("qualification") + # Model the closed environment dropping unrecognized caller metadata. + package.php_linux_bootstrap.clean_environment = lambda env, home: { + key: value for key, value in env.items() if key != "PRODUCT_STATUS"} + calls = [] + def qualify(receipt, runner_temp, closed, publish, *, pip_state): + calls.append((receipt, runner_temp, pip_state)) + self.assertNotIn("PRODUCT_STATUS", os.environ) + publish({"outcome": "refused" if refusal else "pass", "cleanup": "pass", + "private_pip_cleanup": "pass", "detail": "x" * (65537 if oversized else 0)}) + if refusal: + raise ValueError("canonical fixture refusal") + package.php_linux_qualification.qualify = qualify + source = "ed15002a156725523b23eefb935a243cb1f84952" + def git_output(arguments, **options): + return source if arguments[-2:] == ["rev-parse", "HEAD"] else "" + output = io.StringIO() + error = None + with patch.dict(os.environ, environment, clear=True), patch.dict(sys.modules, {"scripts": package}), \ + patch.object(sys, "path", list(sys.path)), patch.object(subprocess, "check_output", side_effect=git_output), \ + patch.object(subprocess, "run"), redirect_stdout(output): + try: + exec(compile(self.script, "actual-docs-terminal-step", "exec"), {}) + except ValueError as failure: + error = str(failure) + records = [json.loads(line) for line in output.getvalue().splitlines()] + self.assertFalse(composer.exists(), "Caller Composer cleanup must survive qualification/publication refusal.") + self.assertEqual(1, len(calls)) + self.assertEqual("fixture-root", calls[0][2]["root_identity"]) + return records, error + + def test_product_status_is_retained_before_environment_clear(self): + for status in ("success", "failure"): + with self.subTest(status=status): + records, error = self.collect(status) + self.assertIsNone(error) + self.assertEqual(status, records[0]["caller_product_status"]) + self.assertEqual("pass", records[-1]["composer_state_cleanup"]) + + def test_canonical_refusal_remains_a_refusal_and_cleans_composer(self): + records, error = self.collect("failure", refusal=True) + self.assertEqual("canonical fixture refusal", error) + self.assertEqual("refused", records[0]["outcome"]) + self.assertEqual("failure", records[0]["caller_product_status"]) + self.assertEqual("pass", records[-1]["composer_state_cleanup"]) + + def test_evidence_bound_refuses_without_losing_composer_cleanup(self): + records, error = self.collect(oversized=True) + self.assertIn("exceeds its fixed bound", error) + self.assertEqual([{"composer_state_cleanup": "pass"}], records) + + def test_terminal_interpreter_loader_inputs_are_explicitly_blank(self): + for key in ("LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT"): + self.assertIn(f" {key}: ''\n", self.environment) + + if __name__ == "__main__": unittest.main()