ouroboros/tests/test_build_scripts.py
Ouroboros 9f0c1e880f fix: restore clear macOS DMG installation
Move the macOS install flow to the top of README and /install/, add the approved drag-to-Applications visual, and package the Applications shortcut in the DMG.

Bind the shortcut to final-archive smoke and release proof, then remove only the copied shortcut from SBOM staging before Syft.
2026-08-03 07:05:03 +03:00

1013 lines
47 KiB
Python

"""Regression tests for platform build scripts.
These tests ensure each build script contains the critical Playwright install
steps for its platform policy with the correct env-var flag and that bundled
install steps appear BEFORE the actual PyInstaller command-line invocation.
Linux/Windows/Docker bundle Chromium and WebKit; macOS bundles the Chromium
headless shell and leaves WebKit to the managed runtime cache because the
Playwright WebKit payload does not survive the signed PyInstaller app layout.
In v5.15.x this module also absorbed packaging-asset completeness checks
(formerly tests/test_packaging_assets.py) and the CI release-workflow
contract checks (formerly tests/test_release_workflow.py) so packaging
contracts evolve in one place.
"""
import pathlib
import re
import pytest
REPO_ROOT = pathlib.Path(__file__).parent.parent
REPO = REPO_ROOT
def _read(name: str) -> str:
return (REPO_ROOT / name).read_text(encoding="utf-8")
def _find_pyinstaller_cmd_pos(src: str) -> int:
"""Return the character position of the first line that actually *runs*
PyInstaller (i.e. contains 'PyInstaller' outside a comment/echo line).
Build scripts have comment lines and echo lines mentioning 'PyInstaller'
before the actual invocation; we need the real command line.
"""
for match in re.finditer(r"PyInstaller", src):
# Find the start of the line containing this match.
line_start = src.rfind("\n", 0, match.start()) + 1
line = src[line_start: src.find("\n", match.start())]
stripped = line.strip()
# Skip comment lines (bash: '#', PowerShell: '#') and echo/Write-Host.
if stripped.startswith("#") or stripped.lower().startswith("echo") or stripped.lower().startswith("write-host"):
continue
return match.start()
return -1
# ---------------------------------------------------------------------------
# build.sh (macOS)
# ---------------------------------------------------------------------------
class TestBuildSh:
"""build.sh must bundle Chromium headless shell without bundled macOS WebKit."""
def test_playwright_install_chromium_present(self):
src = _read("build.sh")
assert "playwright install --only-shell chromium" in src, (
"build.sh must call 'playwright install --only-shell chromium' on macOS"
)
assert "Skipping bundled WebKit on macOS" in src, (
"build.sh must document the macOS WebKit packaging policy"
)
assert "Removing stale bundled WebKit payloads from macOS package tree" in src
assert 'rglob(".local-browsers")' in src
assert 'glob("webkit-*")' in src
assert "shutil.rmtree" in src
assert "managed" in src and "Playwright cache" in src, (
"build.sh must leave WebKit available through the managed runtime cache"
)
assert "playwright install webkit" not in src
assert "pw.webkit.launch" not in src
def test_playwright_browsers_path_zero_set(self):
src = _read("build.sh")
assert "PLAYWRIGHT_BROWSERS_PATH=0" in src, (
"build.sh must set PLAYWRIGHT_BROWSERS_PATH=0 for the playwright install step"
)
def test_playwright_install_before_pyinstaller(self):
src = _read("build.sh")
pw_pos = src.find("playwright install --only-shell chromium")
pi_pos = _find_pyinstaller_cmd_pos(src)
assert pw_pos != -1, "playwright install --only-shell chromium not found in build.sh"
assert pi_pos != -1, "PyInstaller command not found in build.sh"
assert pw_pos < pi_pos, (
"playwright install --only-shell chromium must appear BEFORE PyInstaller in build.sh "
f"(found at char {pw_pos}, PyInstaller cmd at {pi_pos})"
)
def test_repo_bundle_generation_before_pyinstaller(self):
src = _read("build.sh")
bundle_pos = src.find("scripts/build_repo_bundle.py")
pi_pos = _find_pyinstaller_cmd_pos(src)
assert bundle_pos != -1, "build.sh must generate repo.bundle before packaging"
assert "--source-branch" in src, "build.sh must pass an explicit source branch for detached-head builds"
assert pi_pos != -1, "PyInstaller command not found in build.sh"
assert bundle_pos < pi_pos, "repo bundle generation must happen before PyInstaller in build.sh"
def test_ripgrep_download_before_pyinstaller(self):
src = _read("build.sh")
rg_pos = src.find("download_ripgrep_standalone.sh")
pi_pos = _find_pyinstaller_cmd_pos(src)
assert rg_pos != -1
assert pi_pos != -1
assert rg_pos < pi_pos
def test_ripgrep_download_script_verifies_checksum(self):
src = _read("scripts/download_ripgrep_standalone.sh")
assert ".sha256" in src
assert "hashlib.sha256" in src
assert "SHA256 mismatch" in src
def test_repo_bundle_delegates_release_tag_validation_to_python_ssot(self):
src = _read("build.sh")
assert 'scripts/build_repo_bundle.py' in src
assert 'refs/tags/$RELEASE_TAG' not in src
assert 'git tag --points-at HEAD' not in src
assert 'OUROBOROS_RELEASE_TAG="$RELEASE_TAG"' not in src
def test_repo_bundle_script_has_no_duplicate_annotated_tag_guard(self):
src = _read("build.sh")
assert 'git cat-file -t "refs/tags/$RELEASE_TAG"' not in src
assert 'requires annotated git tag' not in src
def test_packaged_cli_wrappers_are_copied_before_signing_and_dmg(self):
src = _read("build.sh")
wrapper_pos = src.find("Installing packaged CLI wrappers")
precompile_pos = src.find("Precompiling Python bytecode inside app bundle")
sign_pos = src.find("=== Signing Ouroboros.app ===")
dmg_pos = src.find("=== Creating DMG ===")
assert wrapper_pos != -1
assert "Contents/Resources/bin" in src
assert "packaging/cli/ouroboros" in src
assert "packaging/cli/install-ouroboros-cli" in src
assert "PYTHONDONTWRITEBYTECODE=1" in src
assert "PYTHONPYCACHEPREFIX" in src
# WA6: precompile + seal .pyc (not delete) so the signature stays valid.
assert "compileall" in src and "--invalidation-mode unchecked-hash" in src
assert sign_pos != -1 and wrapper_pos < sign_pos
assert precompile_pos != -1 and precompile_pos < sign_pos # sealed before signing
assert dmg_pos != -1 and wrapper_pos < dmg_pos
def test_precompiles_and_seals_bytecode_before_signing(self):
"""WA6: precompile .pyc so they are SEALED inside the signature (rather than
deleted, letting the runtime regenerate them inside the bundle and break the
codesign seal). xattr hygiene + the --strict verify gate remain."""
src = _read("build.sh")
precompile_pos = src.find("compileall")
sign_pos = src.find("=== Signing Ouroboros.app ===")
assert precompile_pos != -1, "build.sh must precompile bytecode before signing"
assert "--invalidation-mode unchecked-hash" in src
assert precompile_pos < sign_pos
assert 'xattr -cr "$APP_PATH"' in src
assert "codesign --verify --strict" in src
# CRITICAL-1: the build-time PYTHONDONTWRITEBYTECODE=1 + PYTHONPYCACHEPREFIX
# (exported at the top of build.sh) MUST be neutralized for the compileall
# command, else it writes ZERO in-tree .pyc and the seal seals nothing.
assert "env -u PYTHONDONTWRITEBYTECODE -u PYTHONPYCACHEPREFIX" in src, (
"build.sh must neutralize PYTHONDONTWRITEBYTECODE/PYTHONPYCACHEPREFIX "
"around compileall (else the seal covers no bytecode — WA6 no-op)"
)
# Post-condition guard: fail the build if no in-bundle .pyc actually landed.
assert "precompile produced no in-bundle .pyc" in src, (
"build.sh must verify .pyc actually landed in-bundle after compileall "
"(a silent no-op would otherwise ship a self-resealing bundle)"
)
def test_macos_dmg_includes_finder_install_layout(self):
src = _read("build.sh")
assert "dmg-stage" in src
assert 'ln -s /Applications "$DMG_STAGE_DIR/Applications"' in src
assert "Install CLI.command" in src
assert "install-ouroboros-cli-macos.command" in src
def test_macos_dmg_cli_command_delegates_installed_app_before_refusing_dmg(self):
src = _read("packaging/cli/install-ouroboros-cli-macos.command")
installed_check = src.find('if [ -x "$INSTALLED_CLI" ]; then')
refusal = src.find('Install Ouroboros.app to /Applications')
assert installed_check != -1
assert refusal != -1
assert installed_check < refusal
def test_symlink_normalizer_skips_playwright_browser_bundles(self):
src = _read("build.sh")
assert "_should_skip_symlink" in src, (
"build.sh should centralize the macOS symlink-skip guard for bundled "
"browser bundles"
)
assert ".local-browsers" in src, (
"build.sh must skip symlink normalization inside Playwright's bundled "
"browser tree on macOS"
)
assert ".app" in src and ".framework" in src, (
"build.sh must preserve nested macOS app/framework bundles during "
"symlink normalization"
)
def test_compileall_env_neutralization_actually_seals_bytecode(tmp_path):
"""BEHAVIORAL guard for WA6 / CRITICAL-1 (the string checks above can't catch
an env re-inheritance regression). Every build script exports
PYTHONDONTWRITEBYTECODE=1 + PYTHONPYCACHEPREFIX at the top; that SUPPRESSES
in-tree .pyc. The codesign-seal compileall step therefore MUST run with those
vars neutralized — otherwise it seals ZERO bytecode and the macOS .app
re-generates .pyc into its own signed bundle at runtime (the original bug).
This asserts the underlying mechanism end-to-end with the real interpreter:
- WITH the suppression env -> compileall writes NO in-tree .pyc (the bug),
- WITHOUT it (neutralized) -> .pyc land in-tree (the fix the seal relies on).
"""
import os
import subprocess
import sys
def _compile(extra_env: dict) -> list:
pkg = tmp_path / ("sup" if extra_env else "clean")
pkg.mkdir()
(pkg / "mod.py").write_text("x = 1\n", encoding="utf-8")
env = dict(os.environ)
env.pop("PYTHONDONTWRITEBYTECODE", None)
env.pop("PYTHONPYCACHEPREFIX", None)
env.update(extra_env)
subprocess.run(
[sys.executable, "-m", "compileall", "-q", "-f",
"--invalidation-mode", "unchecked-hash", str(pkg)],
env=env, check=False, capture_output=True,
)
return list(pkg.rglob("*.pyc"))
# The suppression env every build script exports -> no in-tree .pyc.
suppressed = _compile({
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONPYCACHEPREFIX": str(tmp_path / "prefix"),
})
assert suppressed == [], (
"PYTHONDONTWRITEBYTECODE/PYTHONPYCACHEPREFIX must suppress in-tree .pyc — "
"this is WHY the build scripts must neutralize them around compileall"
)
# Neutralized env (what the build scripts do around compileall) -> .pyc sealed.
sealed = _compile({})
assert sealed, (
"with the suppression env neutralized, compileall MUST write in-tree .pyc "
"(the macOS codesign seal depends on it); if this fails the WA6 fix is a no-op"
)
# ---------------------------------------------------------------------------
# build_linux.sh (Linux)
# ---------------------------------------------------------------------------
class TestBuildLinuxSh:
"""build_linux.sh must install Chromium/WebKit with PLAYWRIGHT_BROWSERS_PATH=0 before PyInstaller."""
def test_playwright_install_chromium_present(self):
src = _read("build_linux.sh")
assert "playwright install chromium webkit" in src
assert "playwright install-deps chromium webkit" in src
def test_playwright_browsers_path_zero_set(self):
src = _read("build_linux.sh")
assert "PLAYWRIGHT_BROWSERS_PATH=0" in src
def test_playwright_install_before_pyinstaller(self):
src = _read("build_linux.sh")
deps_pos = src.find("playwright install-deps chromium webkit")
pw_pos = src.find("playwright install chromium webkit")
pi_pos = _find_pyinstaller_cmd_pos(src)
assert deps_pos != -1
assert pw_pos != -1
assert pi_pos != -1
assert deps_pos < pw_pos
assert pw_pos < pi_pos, (
"playwright install chromium webkit must appear BEFORE PyInstaller in build_linux.sh"
)
def test_repo_bundle_generation_before_pyinstaller(self):
src = _read("build_linux.sh")
bundle_pos = src.find("scripts/build_repo_bundle.py")
pi_pos = _find_pyinstaller_cmd_pos(src)
assert bundle_pos != -1
assert "--source-branch" in src
assert pi_pos != -1
assert bundle_pos < pi_pos, "repo bundle generation must happen before PyInstaller in build_linux.sh"
def test_ripgrep_download_before_pyinstaller(self):
src = _read("build_linux.sh")
rg_pos = src.find("download_ripgrep_standalone.sh")
pi_pos = _find_pyinstaller_cmd_pos(src)
assert rg_pos != -1
assert pi_pos != -1
assert rg_pos < pi_pos
def test_ripgrep_download_script_verifies_checksum(self):
src = _read("scripts/download_ripgrep_standalone.sh")
assert ".sha256" in src
assert "hashlib.sha256" in src
assert "SHA256 mismatch" in src
def test_repo_bundle_delegates_release_tag_validation_to_python_ssot(self):
src = _read("build_linux.sh")
assert 'scripts/build_repo_bundle.py' in src
assert 'refs/tags/$RELEASE_TAG' not in src
assert 'git tag --points-at HEAD' not in src
assert 'OUROBOROS_RELEASE_TAG="$RELEASE_TAG"' not in src
def test_repo_bundle_script_has_no_duplicate_annotated_tag_guard(self):
src = _read("build_linux.sh")
assert 'git cat-file -t "refs/tags/$RELEASE_TAG"' not in src
assert 'requires annotated git tag' not in src
def test_linux_archive_includes_packaged_cli_bin(self):
src = _read("build_linux.sh")
wrapper_pos = src.find("Installing packaged CLI wrappers")
precompile_pos = src.find("Precompiling Python bytecode in archive payload")
archive_pos = src.find("=== Creating archive ===")
assert wrapper_pos != -1
assert "dist/Ouroboros/bin" in src
assert "packaging/cli/ouroboros" in src
assert "packaging/cli/install-ouroboros-cli" in src
assert "PYTHONDONTWRITEBYTECODE=1" in src
assert "PYTHONPYCACHEPREFIX" in src
# WA6 parity: precompile instead of deleting .pyc.
assert "compileall" in src and "--invalidation-mode unchecked-hash" in src
# CRITICAL-1 parity: neutralize the suppression env around compileall so the
# in-tree .pyc actually get written (start-speed parity with the macOS seal).
assert "env -u PYTHONDONTWRITEBYTECODE -u PYTHONPYCACHEPREFIX" in src, (
"build_linux.sh must neutralize PYTHONDONTWRITEBYTECODE/PYTHONPYCACHEPREFIX "
"around compileall (else compileall writes no in-tree .pyc)"
)
assert precompile_pos != -1 and precompile_pos < archive_pos
assert archive_pos != -1 and wrapper_pos < archive_pos
def test_posix_cli_wrappers_search_pyinstaller_internal_root_without_env_trust(self):
for name in ("packaging/cli/ouroboros", "packaging/cli/install-ouroboros-cli"):
src = _read(name)
assert 'OUROBOROS_PACKAGED_BUNDLE_ROOT:-' not in src
assert '$SCRIPT_DIR/../_internal' in src
# ---------------------------------------------------------------------------
# build_windows.ps1 (Windows / PowerShell)
# ---------------------------------------------------------------------------
class TestBuildWindowsPs1:
"""build_windows.ps1 must install Chromium/WebKit with PLAYWRIGHT_BROWSERS_PATH=0 before PyInstaller."""
def test_playwright_install_chromium_present(self):
src = _read("build_windows.ps1")
assert "playwright install --only-shell chromium" in src
assert "playwright install webkit" in src
def test_playwright_browsers_path_zero_set(self):
src = _read("build_windows.ps1")
# PowerShell syntax: $env:PLAYWRIGHT_BROWSERS_PATH = "0"
assert 'PLAYWRIGHT_BROWSERS_PATH' in src and '"0"' in src, (
"build_windows.ps1 must set PLAYWRIGHT_BROWSERS_PATH to '0'"
)
def test_playwright_install_before_pyinstaller(self):
src = _read("build_windows.ps1")
pw_pos = src.find("playwright install --only-shell chromium")
webkit_pos = src.find("playwright install webkit")
pi_pos = _find_pyinstaller_cmd_pos(src)
assert pw_pos != -1
assert webkit_pos != -1
assert pi_pos != -1
assert pw_pos < webkit_pos
assert pw_pos < pi_pos, (
"playwright install --only-shell chromium must appear BEFORE PyInstaller in build_windows.ps1"
)
assert webkit_pos < pi_pos, (
"playwright install webkit must appear BEFORE PyInstaller in build_windows.ps1"
)
def test_windows_build_has_path_length_guard(self):
src = _read("build_windows.ps1")
assert "Checking Windows archive path lengths" in src
assert "Length -gt 200" in src
assert "paths longer than 200 chars" in src
def test_windows_build_prunes_optional_long_chromium_paths(self):
src = _read("build_windows.ps1")
assert "Pruning optional Chromium resources with long Windows paths" in src
assert "PrivacySandboxAttestationsPreloaded" in src
assert "reading_mode_gdocs_helper" in src
def test_repo_bundle_generation_before_pyinstaller(self):
src = _read("build_windows.ps1")
bundle_pos = src.find("scripts/build_repo_bundle.py")
pi_pos = _find_pyinstaller_cmd_pos(src)
assert bundle_pos != -1
assert "--source-branch" in src
assert pi_pos != -1
assert bundle_pos < pi_pos, "repo bundle generation must happen before PyInstaller in build_windows.ps1"
def test_ripgrep_download_before_pyinstaller(self):
src = _read("build_windows.ps1")
rg_pos = src.find("download_ripgrep_standalone.ps1")
pi_pos = _find_pyinstaller_cmd_pos(src)
assert rg_pos != -1
assert pi_pos != -1
assert rg_pos < pi_pos
def test_ripgrep_download_script_verifies_checksum(self):
src = _read("scripts/download_ripgrep_standalone.ps1")
assert ".sha256" in src
assert "Get-FileHash" in src
assert "SHA256 mismatch" in src
def test_repo_bundle_delegates_release_tag_validation_to_python_ssot(self):
src = _read("build_windows.ps1")
assert 'scripts/build_repo_bundle.py' in src
assert 'refs/tags/$ReleaseTag' not in src
assert 'git tag --points-at HEAD' not in src
assert '$env:OUROBOROS_RELEASE_TAG' not in src
def test_repo_bundle_script_has_no_duplicate_annotated_tag_guard(self):
src = _read("build_windows.ps1")
assert 'git cat-file -t "refs/tags/$ReleaseTag"' not in src
assert 'annotated git tag' not in src
def test_windows_archive_includes_packaged_cli_bin(self):
src = _read("build_windows.ps1")
wrapper_pos = src.find("Installing packaged CLI wrappers")
precompile_pos = src.find("Precompiling Python bytecode in archive payload")
length_pos = src.find("Checking Windows archive path lengths")
archive_pos = src.find("=== Creating archive ===")
assert wrapper_pos != -1
assert "dist\\Ouroboros\\bin" in src
assert "packaging\\cli\\ouroboros.cmd" in src
assert "packaging\\cli\\install-ouroboros-cli.cmd" in src
assert "PYTHONDONTWRITEBYTECODE" in src
assert "PYTHONPYCACHEPREFIX" in src
# WA6 parity: precompile instead of deleting .pyc.
assert "compileall" in src and "--invalidation-mode unchecked-hash" in src
# CRITICAL-1 parity (PowerShell form): neutralize the suppression env around
# compileall via Remove-Item Env: so in-tree .pyc actually get written.
assert "Remove-Item Env:PYTHONDONTWRITEBYTECODE" in src, (
"build_windows.ps1 must neutralize PYTHONDONTWRITEBYTECODE around "
"compileall (else compileall writes no in-tree .pyc)"
)
# v6.36.1: compileall must TOLERATE per-file failures (a known broken Tcl/Tix
# WmDefault.py) — parity with the POSIX `|| true`. Under pwsh 7.4+ a native
# non-zero exit aborts the build unless Stop-on-native-error is neutralized
# and $LASTEXITCODE is reset.
assert '$ErrorActionPreference = "Continue"' in src, (
"build_windows.ps1 must neutralize Stop-on-native-error around compileall "
"so a single broken bundled file does not fail the Windows build"
)
assert "$global:LASTEXITCODE = 0" in src, (
"build_windows.ps1 must reset $LASTEXITCODE after the tolerant compileall"
)
assert length_pos != -1 and wrapper_pos < length_pos
assert precompile_pos != -1 and archive_pos != -1 and precompile_pos < archive_pos
def test_windows_cli_wrappers_search_pyinstaller_internal_root(self):
for name in ("packaging/cli/ouroboros.cmd", "packaging/cli/install-ouroboros-cli.cmd"):
src = _read(name)
assert r"%ROOT%\_internal\repo.bundle" in src
assert 'set "ROOT=%ROOT%\\_internal"' in src
# ---------------------------------------------------------------------------
# Dockerfile (Docker / web runtime)
# ---------------------------------------------------------------------------
class TestDockerfile:
"""Dockerfile must install Playwright Chromium/WebKit binaries so browser tools work
out of the box in the container without additional setup."""
def test_playwright_install_chromium_present(self):
src = _read("Dockerfile")
assert "playwright install chromium webkit" in src, (
"Dockerfile must call 'playwright install chromium webkit' to bundle the browsers"
)
def test_playwright_browsers_path_zero_set(self):
src = _read("Dockerfile")
assert "PLAYWRIGHT_BROWSERS_PATH=0" in src, (
"Dockerfile must set PLAYWRIGHT_BROWSERS_PATH=0 so Chromium installs "
"inside the pip package tree (not into a user cache that won't survive "
"image layer boundaries)"
)
def test_playwright_install_deps_present(self):
"""Dockerfile must use 'playwright install-deps chromium webkit' (the authoritative
Playwright dependency resolver) rather than a hand-curated apt library list.
This ensures all runtime native libs required by Chromium/WebKit are present."""
src = _read("Dockerfile")
assert "playwright install-deps chromium webkit" in src, (
"Dockerfile must call 'playwright install-deps chromium webkit' to install all "
"native system libraries required by Chromium/WebKit via Playwright's authoritative "
"dependency resolver"
)
def test_install_deps_before_install_chromium(self):
"""Native system dependencies must be installed BEFORE the Chromium binary
is downloaded, so the binary can find its runtime libraries on first launch."""
src = _read("Dockerfile")
deps_pos = src.find("playwright install-deps chromium webkit")
src.find("playwright install chromium webkit")
# binary_pos must not match the install-deps line itself
# find the standalone 'playwright install chromium webkit' (not install-deps)
import re as _re
binary_match = _re.search(r"(?<!install-deps )playwright install chromium webkit", src)
assert deps_pos != -1, "playwright install-deps chromium webkit not found in Dockerfile"
assert binary_match is not None, "standalone playwright install chromium webkit not found in Dockerfile"
assert deps_pos < binary_match.start(), (
"playwright install-deps must appear BEFORE playwright install chromium webkit in Dockerfile"
)
def test_pip_install_before_playwright_install_deps(self):
"""pip install must appear BEFORE playwright install-deps chromium webkit — the
playwright Python package must be importable when install-deps runs."""
src = _read("Dockerfile")
pip_pos = src.find("pip install")
deps_pos = src.find("playwright install-deps chromium webkit")
assert pip_pos != -1, "pip install step not found in Dockerfile"
assert deps_pos != -1, "playwright install-deps chromium webkit not found in Dockerfile"
assert pip_pos < deps_pos, (
"pip install must appear BEFORE playwright install-deps chromium webkit in Dockerfile "
f"(pip at char {pip_pos}, install-deps at {deps_pos})"
)
def test_pip_install_before_all_playwright_invocations(self):
"""pip install must appear BEFORE every ``python3 -m playwright ...`` invocation
in the Dockerfile — both ``install-deps`` and ``install chromium webkit``.
If *any* playwright invocation precedes pip install, ModuleNotFoundError occurs."""
src = _read("Dockerfile")
pip_pos = src.find("pip install")
assert pip_pos != -1, "pip install step not found in Dockerfile"
import re as _re
playwright_invocations = [
m.start() for m in _re.finditer(r"python3 -m playwright", src)
]
assert playwright_invocations, "No 'python3 -m playwright' invocations found in Dockerfile"
earliest_playwright = min(playwright_invocations)
assert pip_pos < earliest_playwright, (
"pip install must appear BEFORE the earliest 'python3 -m playwright' invocation "
f"in the Dockerfile (pip at char {pip_pos}, earliest playwright at {earliest_playwright}). "
f"Found {len(playwright_invocations)} playwright invocation(s) at positions: "
f"{playwright_invocations}"
)
# ---------------------------------------------------------------------------
# scripts/build_repo_bundle.py (release tag SSOT)
# ---------------------------------------------------------------------------
class TestRepoBundleReleaseTagGuard:
"""The platform scripts delegate release-tag integrity to the Python
bundler so the invariant has one fail-closed implementation."""
def test_bundler_requires_release_tag_at_head(self):
src = _read("scripts/build_repo_bundle.py")
assert "def _resolve_release_tag" in src
assert '"tag", "--points-at", "HEAD"' in src
assert "does not match VERSION" in src
def test_bundler_requires_annotated_tag(self):
src = _read("scripts/build_repo_bundle.py")
assert "def _verify_release_tag_in_repo" in src
assert '"cat-file", "-t"' in src
assert "is not an annotated tag" in src
def test_bundler_checks_tag_points_at_head_sha(self):
src = _read("scripts/build_repo_bundle.py")
assert '"rev-parse", "HEAD"' in src
assert '"rev-list", "-1"' in src
assert "does not point at HEAD" in src
# ---------------------------------------------------------------------------
# .github/workflows/ci.yml + build.sh — macOS code signing & notarization
# ---------------------------------------------------------------------------
class TestMacOSSigning:
"""The CI build job and build.sh together implement optional macOS code
signing and notarization. The contracts here prevent
regression of the GitHub Actions `secrets.*`-in-step-`if:` pitfall, the
build-script env override / optional-notarytool gate, the keychain
cleanup guard, and the stapler-failure-as-soft-warning behaviour.
See docs/DEVELOPMENT.md::"GitHub Actions: secrets in step-level if
conditions" for the rationale.
"""
_CI_PATH = ".github/workflows/ci.yml"
_SIGNING_SECRETS = (
"BUILD_CERTIFICATE_BASE64",
"P12_PASSWORD",
"KEYCHAIN_PASSWORD",
"APPLE_TEAM_ID",
)
_NOTARIZE_SECRETS = (
"APPLE_ID",
"APPLE_APP_SPECIFIC_PASSWORD",
)
@staticmethod
def _build_job_header(src: str) -> str:
"""Slice the build job header (everything between ` build:` and the
first ` steps:` underneath it) so signing-secret env mappings can
be located without false positives from later step-level env blocks."""
build_idx = src.find("\n build:\n")
assert build_idx != -1, "build job not found in ci.yml"
steps_idx = src.find("\n steps:", build_idx)
assert steps_idx != -1, "build.steps: not found in ci.yml"
return src[build_idx:steps_idx]
@staticmethod
def _iter_step_if_blocks(src: str):
"""Yield every `if:` expression in the workflow as a flat string.
Catches BOTH step-level and job-level `if:` blocks (the
`Unrecognized named-value: 'secrets'` rejection applies at every
level, so checking job-level too is strictly more conservative).
Heuristic: collect lines starting from `if:` until the next YAML
key starts (a line whose first non-space char is `-` or whose
stripped form contains a `:`). Known limitation: a future `if:`
whose continuation lines legitimately contain `:` (string literals,
nested expressions) would be split prematurely; the current ci.yml
has no such case. If that pattern is added, switch to a real YAML
parser walking each step's `if` field.
"""
lines = src.splitlines()
in_if = False
block: list[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith("if:"):
if in_if and block:
yield " ".join(block)
in_if = True
block = [stripped]
continue
if in_if:
# Continuation: indented, not a new YAML key, not a step start.
if stripped and not stripped.startswith("- ") and ":" not in stripped:
block.append(stripped)
else:
yield " ".join(block)
in_if = False
block = []
if in_if and block:
yield " ".join(block)
def test_ci_keeps_signing_values_step_scoped(self):
"""The build job exposes one non-secret gate globally and gives
credential values only to the first-party steps that need them."""
src = _read(self._CI_PATH)
header = self._build_job_header(src)
assert "HAS_APPLE_SIGNING:" in header
assert "matrix.os == 'macos-latest'" in header
for secret in self._SIGNING_SECRETS:
assert f"secrets.{secret} != ''" in header
assert not re.search(rf"^\s+{secret}:\s", header, re.MULTILINE)
import_idx = src.index("- name: Import Apple signing certificate")
import_region = src[import_idx : import_idx + 900]
for secret in ("BUILD_CERTIFICATE_BASE64", "P12_PASSWORD", "KEYCHAIN_PASSWORD"):
assert f"{secret}: ${{{{ secrets.{secret} }}}}" in import_region
build_idx = src.index("- name: Build macOS app")
build_region = src[build_idx : build_idx + 1000]
for secret in ("APPLE_TEAM_ID", *self._NOTARIZE_SECRETS):
assert f"{secret}: ${{{{ secrets.{secret} }}}}" in build_region
def test_release_waits_for_non_provider_smoke_jobs(self):
src = _read(self._CI_PATH)
release_idx = src.find("\n release:\n")
assert release_idx != -1, "release job not found"
release_block = src[release_idx:]
needs_line = next(
line.strip()
for line in release_block.splitlines()
if line.strip().startswith("needs:")
)
for job in ("marker-guards", "ui-smoke", "docker-ui-smoke", "docker-portable-test", "skill-smoke"):
assert job in needs_line, f"release job must wait for {job}"
assert "OUROBOROS_EXPECT_BROWSER_ENGINES: chromium,webkit" in src
assert "Run Docker browser tools Chromium/WebKit smoke" in src
assert "tests/test_browser_tools_smoke.py -m browser" in src
assert "-e OUROBOROS_EXPECT_BROWSER_ENGINES=chromium,webkit" in src
def test_marker_guard_uses_pipefail(self):
src = _read(self._CI_PATH)
guard_idx = src.find("Guard non-empty browser marker lanes")
assert guard_idx != -1, "marker guard step not found"
guard_block = src[guard_idx:guard_idx + 700]
assert "set -euo pipefail" in guard_block
def test_ci_uses_env_context_for_condition(self):
"""No `if:` expression in ci.yml (step-level OR job-level) may
reference `secrets.*`.
GitHub Actions rejects `secrets.*` in `if:` with
`Unrecognized named-value: 'secrets'`. Always use `env.*` instead
(see the non-secret job-level gate test above). The parser used here
catches both step-level and job-level `if:` blocks deliberately —
the rejection applies at every level, so a job-level violation
would also break the workflow.
"""
src = _read(self._CI_PATH)
offending = [
block for block in self._iter_step_if_blocks(src)
if "secrets." in block
]
assert not offending, (
"secrets.* must not appear in any step-level if-condition "
"(derive a non-secret job-level env gate and reference env.* instead). "
f"Offenders: {offending}"
)
def test_ci_import_gates_on_full_secret_set(self):
"""The import step uses the non-secret gate derived from the full
required signing set."""
src = _read(self._CI_PATH)
import_idx = src.find("Import Apple signing certificate")
assert import_idx != -1, (
"Apple signing-certificate Import step not found in ci.yml — "
"the macOS signing path is missing"
)
region = src[import_idx:import_idx + 800]
assert "env.HAS_APPLE_SIGNING == 'true'" in region
def test_ci_cleanup_keychain_step_present(self):
"""A `Cleanup keychain` step must run with `if: always() &&
matrix.os == 'macos-latest' && env.HAS_APPLE_SIGNING == 'true'`
so signing material never persists across runs even when the build
itself fails, and the bash-only `security` invocation never fires
on Linux/Windows shards."""
src = _read(self._CI_PATH)
# Match the actual STEP definition (`- name: Cleanup keychain`), not
# any prose mentioning the step elsewhere in the workflow file (e.g.
# an explanatory comment in the Import step that references the later
# Cleanup step would match a bare substring search). The `- name:`
# anchor pins the assertion to the real step header.
cleanup_anchor = "- name: Cleanup keychain"
assert cleanup_anchor in src, (
"ci.yml must include a `- name: Cleanup keychain` step that "
"deletes the temporary signing keychain after every macOS build"
)
cleanup_idx = src.find(cleanup_anchor)
cleanup_region = src[cleanup_idx:cleanup_idx + 500]
assert "always()" in cleanup_region, (
"Cleanup keychain must run with `if: always()` so it fires on "
"build failures too"
)
assert "matrix.os == 'macos-latest'" in cleanup_region, (
"Cleanup keychain must gate on matrix.os == 'macos-latest' so "
"the bash-only `security delete-keychain` invocation does not "
"fire on Linux/Windows shards"
)
assert "env.HAS_APPLE_SIGNING == 'true'" in cleanup_region, (
"Cleanup keychain must gate on env.HAS_APPLE_SIGNING so "
"it does not try to delete a keychain that was never created"
)
def test_build_sh_signing_identity_env_override(self):
"""build.sh must allow the signing identity to be overridden via env
AND auto-detect from the keychain when env is unset/empty.
The previous hardcoded `Developer ID Application: <Maintainer>
(<TeamID>)` default broke any fork whose imported cert had a
different CN (`codesign: no identity found`). The current contract:
a non-empty `SIGN_IDENTITY` env wins; otherwise auto-detect via
`security find-identity -v -p codesigning`.
"""
src = _read("build.sh")
# The empty-env auto-detect block must check `${SIGN_IDENTITY:-}`
# explicitly (not `$SIGN_IDENTITY` alone, which would be unbound
# under `set -u`).
assert re.search(
r'\[\s*-z\s*"\$\{SIGN_IDENTITY:-\}"\s*\]',
src,
), (
"build.sh must guard the auto-detect block with "
"`[ -z \"${SIGN_IDENTITY:-}\" ]` so the env var wins when set "
"and auto-detect runs only when env is unset/empty"
)
assert "security find-identity" in src and "-p codesigning" in src, (
"build.sh must call `security find-identity -v -p codesigning` "
"to auto-detect the signing identity from the keychain when "
"SIGN_IDENTITY is not set externally"
)
# The hardcoded maintainer-specific default must be GONE (it caused
# `codesign: no identity found` on forks; replaced by auto-detect).
# We pin a substring that any future re-introduction would trip on.
assert 'SIGN_IDENTITY="${SIGN_IDENTITY:-Developer ID Application:' not in src, (
"build.sh must NOT carry a hardcoded maintainer-specific "
"Developer ID Application default — it breaks forks whose "
"imported cert has a different CN. Use auto-detect via "
"`security find-identity` instead."
)
def test_build_sh_notarization_optional(self):
"""build.sh must include an optional notarization block guarded on
APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD, calling
`xcrun notarytool submit` followed by `xcrun stapler staple`."""
src = _read("build.sh")
assert "xcrun notarytool submit" in src, (
"build.sh must call `xcrun notarytool submit` to upload the "
"DMG for Apple notarization when credentials are configured"
)
assert "xcrun stapler staple" in src, (
"build.sh must call `xcrun stapler staple` after a successful "
"notarytool submission so the ticket is attached to the DMG"
)
# The notarization block must be guarded on the three notarytool
# credential env vars, otherwise builds without an Apple ID hard-fail.
for var in ("APPLE_ID", "APPLE_TEAM_ID", "APPLE_APP_SPECIFIC_PASSWORD"):
assert var in src, (
f"build.sh notarization block must reference {var} so it "
f"is gated on the full credential set"
)
def test_build_sh_stapler_failure_is_soft(self):
"""`xcrun stapler staple` must be wrapped in an `if/then/else`
(or paired with `||`) so a transient stapler failure becomes a
warning instead of aborting the build under `set -e`.
Apple's stapler service can fail intermittently after a successful
`notarytool submit` (CDN propagation lag, transient 5xx). A
signed-and-notarized-but-unstapled DMG is still functional —
Gatekeeper fetches the ticket online on first launch — so a
stapler hiccup must not delete the macOS artifact from the
release.
"""
src = _read("build.sh")
# Find the stapler invocation and check it is inside an `if` head
# (i.e. `if xcrun stapler staple ...; then`) OR followed by `||`.
# The simplest robust check: locate the line, then verify either
# (a) it begins with `if ` after stripping leading whitespace, or
# (b) it ends with ` || ...` style continuation.
# Only inspect actual code lines — strip both whole-line bash comments
# (`# …`) and inline trailing comments (`code # …`) before testing.
stapler_lines = []
for raw in src.splitlines():
code = raw.split("#", 1)[0]
if "xcrun stapler staple" in code:
stapler_lines.append(code)
assert stapler_lines, (
"build.sh must call `xcrun stapler staple` (notarization step)"
)
for line in stapler_lines:
stripped = line.strip()
wrapped_in_if = stripped.startswith("if ") and stripped.endswith("; then")
soft_or = "||" in stripped
assert wrapped_in_if or soft_or, (
"build.sh `xcrun stapler staple` invocation must be guarded "
"(`if xcrun stapler staple ...; then ... else WARN ... fi` "
"or `xcrun stapler staple ... || echo WARN`) so a transient "
"stapler failure does not abort the build under `set -e` and "
f"silently drop the macOS DMG. Offending line: {stripped!r}"
)
# ===========================================================================
# Packaging asset completeness (merged from former test_packaging_assets.py)
# and CI release workflow checks (merged from former test_release_workflow.py).
# All three originally exercised the same /repo packaging surface; the merge
# keeps them under one module so packaging contracts evolve in one place.
# ===========================================================================
# ----- shared helpers for packaging asset tests -----
_REPO_PATH = REPO_ROOT
_BUNDLE_FILES_PRESENT = (_REPO_PATH / "Ouroboros.spec").exists() and (_REPO_PATH / "launcher.py").exists()
_PACKAGING_SKIP_REASON = "Bundle-only files (Ouroboros.spec, launcher.py) not present in repo"
def _packaging_launcher_has_bootstrap() -> bool:
launcher = _REPO_PATH / "launcher.py"
bootstrap = _REPO_PATH / "ouroboros" / "launcher_bootstrap.py"
if not launcher.exists() or not bootstrap.exists():
return False
launcher_src = launcher.read_text(encoding="utf-8")
bootstrap_src = bootstrap.read_text(encoding="utf-8")
return (
"from ouroboros.launcher_bootstrap import" in launcher_src
and 'BUNDLE_REPO_NAME = "repo.bundle"' in bootstrap_src
and 'BUNDLE_MANIFEST_NAME = "repo_bundle_manifest.json"' in bootstrap_src
and "ensure_managed_repo" in bootstrap_src
)
_LAUNCHER_HAS_BOOTSTRAP = _packaging_launcher_has_bootstrap()
def _pkg_read(rel: str) -> str:
return (_REPO_PATH / rel).read_text(encoding="utf-8")
# ----- spec/launcher/bundle invariants -----
@pytest.mark.skipif(not _BUNDLE_FILES_PRESENT, reason=_PACKAGING_SKIP_REASON)
def test_spec_bundles_assets_and_icon():
source = _pkg_read("Ouroboros.spec")
assert "('repo.bundle', '.')" in source
assert "('repo_bundle_manifest.json', '.')" in source
assert "('assets', 'assets')" in source
assert "icon='assets/icon.icns'" in source
@pytest.mark.skipif(not _BUNDLE_FILES_PRESENT, reason=_PACKAGING_SKIP_REASON)
def test_spec_bundles_ouroboros_package_for_packaged_cli_bridge():
source = _pkg_read("Ouroboros.spec")
assert "('ouroboros', 'ouroboros')" in source
assert "('python-standalone', 'python-standalone')" in source
assert "('ripgrep-standalone', 'ripgrep-standalone')" in source
@pytest.mark.skipif(
not _LAUNCHER_HAS_BOOTSTRAP,
reason="launcher.py does not import launcher_bootstrap (may be a newer version without bootstrap bridge)",
)
def test_launcher_does_not_exclude_assets_on_bootstrap():
launcher_source = _pkg_read("launcher.py")
bootstrap_source = _pkg_read("ouroboros/launcher_bootstrap.py")
assert '"python-standalone", "assets"' not in launcher_source
assert "from ouroboros.launcher_bootstrap import" in launcher_source
assert 'BUNDLE_REPO_NAME = "repo.bundle"' in bootstrap_source
assert 'BUNDLE_MANIFEST_NAME = "repo_bundle_manifest.json"' in bootstrap_source
assert "ensure_managed_repo(" in bootstrap_source
@pytest.mark.skipif(not _BUNDLE_FILES_PRESENT, reason=_PACKAGING_SKIP_REASON)
def test_spec_retains_cross_platform_packaging_hooks():
source = _pkg_read("Ouroboros.spec")
assert "assets/icon.ico" in source
assert "collect_all as _collect_all" in source
assert "scripts/pyi_rth_pythonnet.py" in source
assert "pythonnet" in source
assert "clr_loader" in source
@pytest.mark.skipif(not _BUNDLE_FILES_PRESENT, reason=_PACKAGING_SKIP_REASON)
def test_launcher_retains_cross_platform_runtime_hooks():
launcher_source = _pkg_read("launcher.py")
assert "embedded_python_candidates" in launcher_source
assert "_prepare_windows_webview_runtime" in launcher_source
assert "git_install_hint()" in launcher_source
assert "create_kill_on_close_job" in launcher_source
assert "kill_process_on_port(port)" in launcher_source
assert "force_kill_pid(child.pid)" in launcher_source
@pytest.mark.skipif(not _BUNDLE_FILES_PRESENT, reason=_PACKAGING_SKIP_REASON)
def test_launcher_preserves_macos_git_setup_path():
launcher_source = _pkg_read("launcher.py")
assert 'subprocess.Popen(["xcode-select", "--install"])' in launcher_source
assert "Install Git (Xcode CLI Tools)" in launcher_source
assert "Installing... A system dialog may appear." in launcher_source
assert '["lsof", "-ti", f"tcp:{port}"]' in launcher_source
def test_cross_platform_build_scripts_are_present():
assert (_REPO_PATH / "build_linux.sh").exists()
assert (_REPO_PATH / "build_windows.ps1").exists()
assert (_REPO_PATH / "scripts" / "download_python_standalone.ps1").exists()
assert (_REPO_PATH / "scripts" / "pyi_rth_pythonnet.py").exists()
def test_build_sh_supports_unsigned_macos_release():
build_source = _pkg_read("build.sh")
assert 'OUROBOROS_SIGN' in build_source
assert 'Skipping signing' in build_source
assert 'Unsigned DMG:' in build_source
# ----- CI release workflow checks (from test_release_workflow.py) -----
def _ci_workflow() -> str:
return (_REPO_PATH / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
def test_ci_release_preflight_validates_tag_matches_version():
workflow = _ci_workflow()
assert "release-preflight:" in workflow
assert "Validate tag matches VERSION" in workflow
assert 'expected_tag = f"v{version}"' in workflow
assert 'tag != expected_tag' in workflow
def test_ci_branch_filters_include_packaging_assets():
workflow = _ci_workflow()
assert "- 'packaging/**'" in workflow
assert "- 'devtools/**'" in workflow
def test_ci_release_prerelease_flag_uses_preflight_output():
workflow = _ci_workflow()
assert "needs.release-preflight.outputs.is_prerelease" in workflow
assert "prerelease: ${{ needs.release-preflight.outputs.is_prerelease == 'true' }}" in workflow
assert "re.search(r'(?:rc|alpha|beta|a|b)\\.?\\d+$'" in workflow
assert "fh.write(f\"is_prerelease={'true' if is_prerelease else 'false'}\\n\")" in workflow
def test_ci_build_job_exports_release_tag_and_fetches_full_history():
workflow = _ci_workflow()
assert "OUROBOROS_RELEASE_TAG: ${{ github.ref_name }}" in workflow
assert "OUROBOROS_MANAGED_SOURCE_BRANCH: ouroboros" in workflow
assert "fetch-depth: 0" in workflow