Skip to content

LDM Testing Protocol

πŸ›‘οΈ Compatibility (Verified Environments)

Architecture Host OS Docker Provider Docker Engine Hardening LDM Version Verified Report
Apple Silicon macOS 26 Tahoe Colima 29.2.1 Colima 2.21.1-pre.4 βœ… verify-apple-silicon-macos-26-tahoe-colima-pass.txt
Apple Silicon macOS 26 Tahoe OrbStack 29.4.0 OrbStack 2.26.5-pre.5 βœ… verify-apple-silicon-macos-26-tahoe-orbstack-pass.txt
Apple Silicon macOS 27 Golden Gate Colima 29.2.1 Colima 2.26.7-pre.6 βœ… verify-apple-silicon-macos-27-golden-gate-colima-pass.txt
Apple Silicon macOS 27 Golden Gate OrbStack 29.4.0 OrbStack 2.26.7-pre.6 βœ… verify-apple-silicon-macos-27-golden-gate-orbstack-pass.txt
Linux Workstation Alpine 3.24 (CI) Native Docker 28.0.4 Linux 2.26.7 βœ… verify-linux-workstation-alpine-3.24-ci-native-docker-pass.txt
Linux Workstation Debian 12 (CI) Native Docker 28.0.4 Linux 2.26.7 βœ… verify-linux-workstation-debian-12-ci-native-docker-pass.txt
Linux Workstation Fedora 44 Native Docker 29.9.1 Linux 2.26.7-pre.6 βœ… verify-linux-workstation-fedora-44-native-docker-pass.txt
Linux Workstation Fedora 44 (CI) Native Docker 28.0.4 Linux 2.26.7 βœ… verify-linux-workstation-fedora-44-ci-native-docker-pass.txt
Linux Workstation Rocky Linux 9.3 (CI) Native Docker 28.0.4 Linux 2.26.7 βœ… verify-linux-workstation-rocky-linux-9.3-ci-native-docker-pass.txt
Linux Workstation Ubuntu 24.04 (CI) Native Docker 28.0.4 Linux 2.26.7 βœ… verify-linux-workstation-ubuntu-24.04-ci-native-docker-pass.txt
Windows PC Windows 11 Native WSL2 WSL 3.0.3.0 29.3.0 WSL2 2.26.7-pre.6 βœ… verify-windows-pc-windows-11-native-wsl2-pass.txt
Windows PC Windows 11 PowerShell 5.1 Docker Desktop 29.4.0 DockerDesktop 2.26.7-pre.6 βœ… verify-windows-pc-windows-11-powershell-5.1-docker-desktop-pass.txt
Windows PC Windows 11 PowerShell 7 Docker Desktop 29.4.0 DockerDesktop 2.26.7-pre.6 βœ… verify-windows-pc-windows-11-powershell-7-docker-desktop-pass.txt

Global Infrastructure

Component Verified Versions Notes
Traefik v3.6.1+ Automatic API version negotiation enabled.
Elasticsearch 8.19.1, 7.17.24 Dual support. ES 8.17.x+ required for Liferay 2025.Q2+ (ES 7 deprecated).

Phase 1: Tool & Security Readiness

πŸ€– Automated (CI / E2E)

ID Test Case Steps Expected Outcome
1.2 Exit Code Integrity ldm doctor --skip-project; echo $? Returns 0 if healthy, 1 if critical.
1.3 Shell Completion ldm completion zsh Generates a valid script.
1.5 Self-Repair ldm upgrade --repair -y Successfully reaches preparation phase.

πŸ› οΈ Manual

ID Test Case Steps Expected Outcome
1.1 No-Sudo Guard sudo ldm run Command blocked with security warning.
1.4 Native Manual man ldm (Run ldm init-common first) Manual page opens correctly.
1.6 Version Management LDM_DEV_MODE=true ldm version --bump beta -y Increments beta version atomically.
1.7 Version Promotion LDM_DEV_MODE=true ldm version --promote -y Prompts for promotion; results in stable.
1.8 Dev Guardrails ldm version --bump patch Blocks execution with safety warning.
1.9 Safety Hatch ldm upgrade (Run while on beta) Prompts to switch back to stable tier.

Scheduled Monitors (Upstream Data)

Some invariants depend on data LDM does not own β€” the tag families published to liferay/dxp and liferay/portal. These cannot be asserted in the unit suite (every test there mocks the registry, by design) and must not gate pull requests, because they fail when upstream changes rather than when the diff does.

Monitor Runs Asserts On failure
scripts/tag_discovery_canary.py
(.github/workflows/tag-discovery-canary.yml)
Mondays 06:00 UTC, plus manual dispatch Every advertised release channel resolves to a tag matching LDM's own patterns; the resolved tag is one the registry still serves (not withdrawn, not a name LDM mis-derived); no tag family has outgrown TAG_DISCOVERY_MAX_PAGES Opens β€” or comments on β€” a single issue labelled tag-discovery-canary, and closes it when the invariants hold again

Run it by hand against the live registry at any time:

python3 scripts/tag_discovery_canary.py    # exit 0 = all invariants hold

It writes nothing outside a temporary directory: LDM_HOME is redirected before ldm_core is imported, so neither the real ~/.ldm nor the 24-hour tag cache is touched.

Deliberately no assertions on specific tag values β€” those change every few weeks, and a canary that needs updating on every Liferay release is a canary that gets switched off. The sweep-headroom check warns from 70% of the ceiling, because the families only ever grow and that warning is the only advance notice anyone gets (LDM-#1650).


Phase 2: Global Infrastructure

πŸ€– Automated (E2E)

ID Test Case Steps Expected Outcome
2.1 Infra Setup ldm infra-setup --search Starts Traefik and ES8. Idempotent.
2.2 Shared DB Mode ldm init <Name> --no-up --no-seed --database-mode shared --db postgresql Valid compose (no depends_on on an undefined service), JDBC URL targets liferay-db-global, derived database name is lowercase, database_mode persisted to meta.
2.3 Shared DB Mode (MySQL) ldm init <Name> --no-up --no-seed --database-mode shared --db mysql Succeeds. JDBC URL targets liferay-db-mysql-global:3306 -- not liferay-db-global, which would aim a MariaDB driver at the PostgreSQL container -- derived database name is lowercase, no depends_on on an undefined service.
2.4 Shared Search Mode ldm init <Name> --no-up --no-seed --search-mode shared search_mode persisted to meta; an ElasticsearchConfiguration.config written under osgi/configs with productionModeEnabled, the global cluster address and a lowercase indexNamePrefix; that directory mounted into the container.

πŸ› οΈ Manual

ID Test Case Steps Expected Outcome
2.2 Infra Restart ldm infra-restart --search Restarts Traefik and ES8 cleanly.
2.3 DNS Alignment Edit /etc/hosts with wrong IP ldm doctor warns about IP mismatch.
2.4 Auto-Healing DNS ldm fix-hosts broken.local Prompts for sudo; fixes the entry.
2.5 Doctor DNS Fix ldm doctor --fix-hosts Batch fixes all missing entries.
2.6 DNS Cleanup (Surg) ldm rm test-dns --clean-hosts Removes specific project host entries.
2.7 DNS Cleanup (Glob) ldm prune --clean-hosts Removes ALL LDM-managed host entries.
2.8 External DB Mode ldm init <Name> --no-up --no-seed --db postgresql --database-mode external (answer the JDBC wizard) No <Name>-db service in the compose file and no depends_on naming one. portal-ext.properties carries the supplied URL/user/password plus jdbc.default.driverClassName and hibernate.dialect. meta records db_type: postgresql, database_mode: external.
2.9 Legacy --db external ldm init <Name> --no-up --no-seed --db external (supply a jdbc:mysql:// URL) Still succeeds. meta is migrated to db_type: mysql, database_mode: external; a MySQL/MariaDB driver and dialect are written.
2.10 Impossible pairings refused ldm init <Name> --db hypersonic --database-mode shared; ldm init <Name> --db external --database-mode shared; ldm init <Name> --db postgresql --database-mode embedded Each exits 1 with a message naming both values. Nothing is created.

Phase 3: Project Lifecycle

πŸ€– Automated (E2E)

ID Test Case Steps Expected Outcome
3.2 Effective Names ldm info <non-ASCII name> Heading shows the verbatim project name; every Liferay:/Database:/Tunnel: row shows the transcoded name Docker holds, and none shows the verbatim form.
3.3 Fresh Project Run ldm run test-run -y --tag-latest Starts a fresh project from seed.
3.4 Hot Deploy (OSGi) Drop test-bundle.jar into deploy/ Verifies deployment via Gogo shell (lb).

🎭 Standalone UI Testing

UI health checks have been decoupled from the strict binary E2E suite to prevent false negatives caused by host VM rendering delays.

  • Run manually: python3 scripts/test_ui.py (Verifies portal login and Control Panel navigation).

πŸ› οΈ Manual

ID Test Case Steps Expected Outcome
3.1 Explicit Init ldm init test-init -y --tag-latest Scaffolds folders/metadata immediately.
3.2 Missing Tag Guard ldm run test-fail -y (no tag) Fails gracefully with "No tag specified".
3.4 Project Collision ldm init test-init (in different dir) Blocks; identifies original path.
3.5 Hostname Collision ldm run --host-name existing.local Blocks execution due to registry conflict.
3.6 Captcha Switch ldm init test-captcha --no-captcha Generates OSGi config and portal property to disable CAPTCHA. Reversible by running without the flag.
3.7 Fast Login Switch ldm run test-fast --fast-login Applies properties to bypass terms of use and password reset prompts. Warns if used with --db hypersonic.
3.8 Feature Flags Switch ldm run test-feature --feature dev LPS-122920 Generates portal properties to enable specific Liferay feature flags.
3.10 License Discovery Drop .xml into deploy/ Doctor identifies the XML as a license.
3.10 Sample Hydration ldm init test-samples --samples Scaffolds project and populates samples.
3.11 Import Integrity ldm import source.zip Verifies source.zip.sha256 before extraction.

Phase 4: Runtime Configuration & UX

πŸ€– Automated (E2E)

ID Test Case Steps Expected Outcome
4.5 Fail-Fast Logic Delete docker-compose.yml ldm logs stops with "Not a project".

πŸ› οΈ Manual

ID Test Case Steps Expected Outcome
4.1 Env Sync ldm env test-init KEY=VAL YAML updated immediately without run.
4.2 Redaction Check ldm -v run test-init (w/ secret) Secret is masked as [REDACTED] in logs.
4.3 WSL Browser ldm run test-init (WSL Only) Opens host browser without UNC errors.
4.4 Intermixed Flags ldm ps -y test-init Global -y recognized after subcommand.

Phase 5: Data Integrity & Recovery

πŸ€– Automated (E2E)

ID Test Case Steps Expected Outcome
5.1 SHA-256 Generation ldm snapshot test-init File contains valid hash of files.tar.gz.
5.2 SHA-256 Verify ldm restore test-init Verify logs show "Integrity verified".

πŸ› οΈ Manual

ID Test Case Steps Expected Outcome
5.3 Corruption Guard Corrupt files.tar.gz manually ldm restore fails with integrity error.
5.4 Project Reset ldm reset state test-init Clears state while container is stopped.
5.5 Verification Bypass ldm restore test-init --no-verify Restores tampered snapshot without error.

Phase 6: Advanced Integrations

πŸ› οΈ Manual

ID Test Case Steps Expected Outcome
6.1 Multi-Node Scaling ldm scale test-init liferay=2 Disables host-mapping; injects cluster.
6.2 Search Migration ldm migrate-search Reconfigures ES settings automatically.
6.3 Cloud Env Sync ldm cloud-fetch project-id Fetches and merges remote variables.

Phase 7: Cleanup & Pruning

πŸ€– Automated (E2E)

ID Test Case Steps Expected Outcome
7.1 Registry Cleanup ldm down test-init --delete Project removed from the global registry.

πŸ› οΈ Manual

ID Test Case Steps Expected Outcome
7.2 SSL Hygiene Check ~/.ldm/infra/proxy/ Configs removed after project teardown.
7.3 Non-Interactive Prune ldm prune -y Silently removes orphaned containers.
7.4 Self-Healing Reg Delete project folder manually Dead path detected and pruned from registry.

πŸ§ͺ Unit Testing & Mocking Guidelines

To prevent test-runner hangs, memory exhaustion, and side-effect leakage in CI pipelines, follow these guidelines when writing unit tests:

  1. Avoid Global built-in / Path Mocking:
  2. Do not globally mock classes or standard libraries like pathlib.Path.exists, pathlib.Path.read_text, builtins.open, or shutil.rmtree.
  3. Patching standard library methods globally can corrupt Python's internal mechanisms (such as timezone updates in time.strftime or mock tracking in unittest.mock) and lead to infinite recursion.
  4. Prefer Real Filesystem Sandboxing:
  5. Use tempfile.TemporaryDirectory to create actual sandbox environments for tests.
  6. Let LDM interact with real, lightweight files on disk. The cleanups inside LDM and TemporaryDirectory contexts will automatically ensure that no files are left behind.
  7. Use Mock Side-Effects for Specific Interceptions:
  8. If files need to be simulated, write a side effect for a specific dependency (like mocking safe_extract to write dummy meta files directly to the temporary directory).
  9. All Test Files MUST Live Under ldm_core/tests/:
  10. testpaths in pyproject.toml and the explicit python -m pytest ldm_core/tests/ invocation in .github/workflows/ci.yml both scan that single directory. A test file placed anywhere else is silently never executed β€” it does not fail, it simply never runs.
  11. This applies even to tests covering code outside the package. Tests for scripts/ modules belong here too: see ldm_core/tests/test_sync_compatibility.py and test_manage_target_nodes.py, which reach their target via sys.path.insert(0, <repo>/scripts) and then import the module by its bare name.
  12. test_no_test_files_outside_suite_directory in ldm_core/tests/test_architectural_contracts.py enforces this, and fails the suite if a test-defining file appears elsewhere. Standalone scripts that merely look like tests (e.g. scripts/test_ui.py, a manual Playwright driver) are exempt, because they define no collectable test functions or classes.

  13. Never Touch the Developer's Real ~/.ldm:

  14. The isolate_ldm_home autouse fixture in ldm_core/tests/conftest.py points LDM_HOME at a fresh temporary directory for every test. Do not remove it, and do not write tests that depend on the real home.
  15. Before it existed, a full run registered pytest tempdirs as real projects β€” entries like tmp58psgp9w and test-project appeared in the developer's ldm list β€” overwrote ~/.ldm/last-command.log (destroying the trace needed to diagnose whatever was last run), and deleted real registry entries whose paths no longer resolved.
  16. LDM_HOME is the only lever available: get_actual_home() rebuilds /Users/<user> from SUDO_USER/USER on macOS and ignores HOME entirely.
  17. Tests that patch get_actual_home themselves are unaffected β€” a patch replaces the function, so it never consults the environment.
  18. This isolates the filesystem only. Docker is covered separately, by the guard below.
  19. test_suite_never_touches_the_developers_real_ldm_home in ldm_core/tests/test_architectural_contracts.py enforces that the fixture is in force.

  20. Never Touch the Machine's Docker (LDM-#1409):

  21. The block_real_docker autouse fixture in ldm_core/tests/conftest.py fails any test that reaches the real daemon, naming the test and the offending command. The boundary is enforced, not merely documented.
  22. It was measured before it existed: 117 Docker invocations from 74 tests in one run. Among them a unit test running docker exec inside the developer's own liferay-search-global, four doing docker rm -f liferay-proxy-global, one creating a real wsl Docker context pointing at ssh://dev@192.168.1.10, and the docker run --rm -v <pytest tmpdir> alpine calls that leaked Created containers holding bind mounts on deleted paths (docker system prune does not collect those).
  23. The hook is at the subprocess boundary, not at CommandRunner. Only 81 of those 117 calls went through CommandRunner.run; the rest come from the ~20 places in ldm_core/{snapshot,runtime,workspace} that call subprocess directly. A guard on the wrapper alone polices two thirds of the problem and reports success.
  24. It intercepts rather than mocks β€” anything that is not a Docker argv is passed straight through to the real function, so the warning above about globally replacing stdlib machinery still holds and is not violated here.
  25. Companion stubs remove the bulk of the traffic at its source: stub_docker_environment_probes answers get_compose_cmd() and get_docker_socket_path() without asking the machine and neutralises reclaim_volume_permissions() (which shells out to alpine chown/chmod), and DockerService's module-scope run_command is stubbed so the whole facade (is_running/exists/stop/rm/inspect/…) cannot reach a daemon.
  26. A test that genuinely needs Docker marks itself @pytest.mark.needs_docker and is allowed through. ldm_core/tests/test_e2e_interactive.py is the only such suite today; it drives the real CLI as a subprocess. Run without them:

    pytest -m "not needs_docker"
    
  27. Three traps this repeatedly walked into, worth knowing before adding a patch:

    • DockerService's statics call a module-scope run_command imported in docker_service.py, so patch.object(self.manager, "run_command") never reaches them. That is LDM-#1365; ten more instances of it survived in test_infra.py alone.
    • Some handlers import inside the function body (cmd_target_status does from ldm_core.utils import run_command), which re-resolves the name at call time and ignores a patch on the handler module's attribute. test_cmd_target_status_offline read as fully mocked while running a real docker info.
    • A class with two setUp definitions silently uses the second one. Adding a patcher to the first has no effect at all.
    • A platform-gated Docker call is invisible to a single-platform measurement. pipelines/run.py calls reclaim_volume_permissions() only when platform.system() == "linux", so a macOS run of this suite measured zero daemon calls through it while Linux CI hit it from three tests. The 117-call baseline above is a macOS number and the Linux one is higher. When auditing for side effects, grep for platform.system() around the call path before trusting a clean local run β€” or force the branch, as reclaim_volume_permissions was verified: patch platform.system to "Linux" for the offending tests, confirm the guard fires, then confirm the stub silences it.
  28. Own Your Scratch Paths (LDM-#1402):

  29. Eight suites built their paths fixtures from the constant /tmp/proj, and let the code under test write into it. Two concurrent runs on one machine therefore raced, and one deleted a file the other was about to read β€” a failure indistinguishable from a real regression in whatever diff was under test.
  30. They now derive from TEST_TMP_ROOT in ldm_core/tests/tmproot.py, unique per pytest process.
  31. If you patch get_actual_home and set a project path, keep the project under that home. _check_global_config_and_network does test_path.relative_to(get_actual_home()) and silently skips its whole volume check when the two are unrelated.

πŸš€ Local E2E Platform Verification Scripts (Multi-OS)

Console encoding on Windows

Windows PowerShell 5.1 defaults to a non-UTF-8 console code page, and that flattens non-ASCII output to ? β€” including in the saved report, which is the project's durable record of what was tested. Measured on v2.18.0-pre.11: the Windows report contained zero non-ASCII bytes.

Three settings are needed, at two different layers, and only the first two were present until LDM-#1465:

Setting Layer
$env:PYTHONUTF8 = 1 what the child process encodes in
[Console]::OutputEncoding how PowerShell decodes what the child wrote
chcp 65001 the console code page the child writes into

The third is the one that was missing. On a non-UTF-8 code page the Windows console can substitute unrepresentable characters at write time, before PowerShell decodes anything β€” so no amount of decoding configuration recovers them.

The script restores the previous code page on exit, since chcp changes the user's console rather than just the script's view of it.

Console encoding and the verbatim-name check

The non-ASCII naming check asserts that ldm info echoes the name the user typed. That assertion depends on the console being able to carry the name, and on Windows PowerShell 5.1 it frequently cannot (LDM-#1452).

[Console]::OutputEncoding = UTF8 and PYTHONUTF8=1 are already set near the top of the PowerShell script, and they are not sufficient. Measured against v2.18.0-pre.11: the saved report contained zero non-ASCII bytes β€” box borders, status glyphs and project names alike had been flattened to ? before anything could compare them.

ldm list --json passed for the same three names in that same run, so LDM does store and report them correctly. The failure was the console, not the product; asserting against a rendering that cannot represent the value is measuring the terminal.

Both scripts now test whether the console can round-trip the name and skip visibly when it cannot β€” never silently, for the same reason as the other "refusing to skip silently" guards here. The JSON assertion still runs and still proves the contract.

This is why the surrounding checks parse --json rather than the rendered table: parse the data, do not string-match a rendering of it.

Disk accounting

Every run reports what it cost (LDM-#1438):

β„Ή  Disk: this run consumed 4 GB in Docker and 1 GB on the host;
   51 GB now free (host: 479 GB).

Disk exhaustion broke verification on three platforms during the v2.18.0 cycle, on machines whose only workload was these scripts β€” and it was invisible until a run died at 100% capacity. Free space was printed once, at the start, and never mentioned again, so a run that consumed 4 GB and reclaimed none looked identical to one that cleaned up perfectly.

A run that reclaims more than it consumes says so, rather than printing a negative.

Note

Until LDM-#2238 that line reached the console and never the report. The finalizer renames the report before this point, and the write still named the old path; tee -a and Out-File -Append both create a missing file, so the text landed in a dotfile nothing reads. Measured across the 224 reports in references/verification-results/, Disk: this run consumed appeared in none of them β€” including the three v2.18.0 platforms the figure exists to explain. The same slip hid the LDM-#2084 built-image sweep, the LDM-#2214 reap and the LDM-#2085 pre-flight refusal, so a run refused for running beside a live project wrote a -fail report naming no cause. Everything after finalisation now goes through trap_report / Write-TrapNote, which resolve the live destination.

--prune-after is available for machines dedicated to verification. It runs docker system prune -af --volumes after the project teardown, so it only reclaims what nothing else owns, and reports what it freed. It is opt-in and never the default: images are shared between projects and expensive to re-pull, the same reasoning LDM-#1414 used to exclude them from project teardown. It cannot be combined with --keep, which preserves the artefacts it would remove.

The #1345 diagnosis check needs an ssh client

The unreachable-node check works by genuinely attempting and genuinely failing an SSH connection β€” the only way to exercise diagnose_remote_context_failure, because the thing under test is the failure.

With no ssh on PATH, Docker's connection helper fails to invoke it rather than failing to connect. The stderr is executable file not found, so neither the phrase table nor the connect to host <h> port <p> regex matches, and LDM falls back to its generic "could not be reached" β€” correct behaviour for an unrecognised failure, which the assertion then reads as a regression.

Observed on Alpine 3.24.1, which ships no ssh client, against v2.18.0-pre.11. The signature is both parsers missing at once and --user absent from the tip: that is a failure which was never an SSH failure.

Both scripts now skip the check visibly when no ssh client is present (LDM-#1444). An ssh client is a dependency the scripts do not control, which is exactly the principle LDM-#1383 set out β€” and the check was verified on macOS only, the same single-platform blind spot that produced LDM-#1425.

Port-conflict diagnostics

When a port check cannot proceed, both scripts name what holds the port (LDM-#1428) rather than aborting with only the port number. The output goes to the durable report, not just the console.

Two sources are queried, and both are needed. Neither can answer the question alone:

Source Answers
docker ps --filter publish=<port> which container holds it β€” the only source that can
lsof / ss / netstat / Get-NetTCPConnection which host process holds it, when no container does

A container-published port is held on the host by the runtime's forwarder, which never identifies the container:

Runtime What the native tool names
Colima / Lima ssh (the Lima SSH mux β€” it forwards ports over SSH)
Docker Desktop (macOS) com.docker.backend
Docker Desktop / WSL2 wslrelay / vpnkit
Native Linux docker-proxy

Measured on Colima with a container publishing 5601, lsof reports ssh while docker ps reports the container. Printing only the lsof line would send the operator chasing a process that is working perfectly, so a recognised forwarder is labelled as such and the reader is pointed back at the container.

It fires at three points: before the check starts (a foreign listener would make the assertion pass for the wrong reason), when the holder cannot be created, and when LDM exits with the wrong code β€” that last one distinguishes "LDM failed to detect a real conflict" from "the fixture never held the port".

Disk space pre-flight

Both scripts refuse to start unless Docker has room to finish (LDM-#1406). The default floor is 15 GB; override with LDM_VERIFY_MIN_DISK_GB.

The floor was 10 GB until LDM-#1430, and the gate is -lt, so a machine with exactly 10 GB passed β€” then died mid-snapshot with ENOSPC, on a run where ldm prune had reclaimed 2.957 GB immediately beforehand. The images alone are ~7.5 GB (liferay/dxp ~5.3, postgres ~0.7, elasticsearch ~1.5) before the running stack grows; 10 GB covered the pull and nothing after it.

The check asks both Docker and the host (LDM-#1435). Neither is sufficient alone:

View Catches Misses
Docker (docker run alpine df /) the VM's own smaller disk host exhaustion
Host (df on the engine's storage path) a full host volume the VM limit

Docker's disk is usually a sparse image on the host filesystem, so the space it reports is a promise the host may be unable to keep. Measured on a developer machine at one moment: Docker reported 77.9 GB free while the host volume had 2.8 GB at 100% capacity. The pre-flight passed and the run died with ENOSPC mid-snapshot.

The host side measures the volume backing the engine, not $HOME: storage is often relocated. On one machine ~/.colima is a symlink to an external drive, where the home volume showed 154 GB free and the volume Docker actually uses showed 480 GB β€” checking $HOME there would fail a run with ample space.

The Docker side asks Docker, not the host:

docker run --rm alpine df -P -k /

On Docker Desktop, Colima and OrbStack the engine's storage lives inside a VM with its own, far smaller disk. Measured on a developer machine mid-verification:

View Free
Host (df /) 109.2 GB
Docker VM 12.5 GB

A host-side check would have waved that run through. This is the same reasoning as Doctor._check_absolute_disk_space (LDM-#1095), and using a throwaway container keeps the .sh and .ps1 implementations identical rather than needing two host-specific ones.

The check runs twice (LDM-#1430). A single up-front check cannot cover a run whose disk usage peaks late: between the pre-flight and the snapshot the run pulls two large images, starts the stack, deploys a bundle and generates logs, so the headroom at the check says little about the headroom at peak. The second check runs immediately before the snapshot β€” the disk-hungry phase, writing a database dump plus a tar of every payload directory β€” and needs 5 GB of remaining headroom. Failing at a named check beats failing inside tar, and a snapshot that cannot write its payload is not a snapshot (LDM-#1429).

The run refuses before pulling anything, so a machine that cannot finish never produces a half-written report. That matters because a report which failed for lack of disk still reads as a defect finding, and these reports are the project's honest record of what was actually tested.

To verify the complete container lifecycle, volume mount synchronization, and CLI options natively on local developer machines:

Fetching the script that matches your binary

The script and the binary are versioned together β€” SCRIPT_VERSION is stamped by scripts/release.py on every bump β€” so a mismatched pair will assert against behaviour that is not there. Fetch the script for the version you are running.

Do not embed ldm version directly in the URL. If the binary cannot determine its version it prints nothing, the URL becomes .../v/scripts/..., and the download returns 404 β€” which reads as a missing script and hides the real failure. That has happened: on Windows a one-file binary that could not unpack to a full %TEMP% produced exactly this (LDM-#1437).

For a pre-release binary, fetch from the release branch, not the tag (LDM-#1613). Tags are immutable under the Burn Rule, so a harness fix landed after vX.Y.Z-pre.N was cut is unreachable from that tag for ever -- the URL keeps serving the broken script. release/vX.Y.Z is the only ref carrying both the fix and the matching SCRIPT_VERSION stamp; master has the fix but the wrong stamp, so it trips the version gate. This was hit for real on LDM-#1610/#1612. A stable version's tag is immutable by design -- it is exactly what shipped, and the release branch is deleted once the version is promoted -- so that case keeps the tag.

LDM_VER="$(ldm version 2>/dev/null)"
if [ -z "$LDM_VER" ]; then
  echo "Could not determine the LDM version -- is 'ldm' on PATH and working?" >&2
  echo "A binary that cannot unpack itself prints nothing here (LDM-#1437)." >&2
  exit 1
fi
# LDM-#1613: pre-releases resolve to the mutable release branch.
case "$LDM_VER" in
  *-pre.*) LDM_REF="release/v${LDM_VER%%-pre.*}" ;;
  *)       LDM_REF="v${LDM_VER}" ;;
esac
curl -fsSL "https://raw.githubusercontent.com/peterrichards-lr/liferay-docker-manager/${LDM_REF}/scripts/verify_e2e_refactor.sh" \
  -o verify_e2e_refactor.sh && chmod +x verify_e2e_refactor.sh
$LdmVer = (ldm version 2>$null)
if (-not $LdmVer) {
    Write-Error "Could not determine the LDM version -- is 'ldm' on PATH and working? A binary that cannot unpack itself prints nothing here (LDM-#1437)."
    exit 1
}
# LDM-#1613: pre-releases resolve to the mutable release branch.
if ($LdmVer -like '*-pre.*') {
    $LdmRef = "release/v" + (($LdmVer -split '-pre\.')[0])
} else {
    $LdmRef = "v$LdmVer"
}
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/peterrichards-lr/liferay-docker-manager/$LdmRef/scripts/verify_e2e_refactor.ps1" -OutFile "verify_e2e_refactor.ps1"

Both check the version is non-empty before building the URL, so a broken binary produces a message naming the real problem instead of a 404. The scripts' own mismatch banner derives the ref the same way, so the hint it prints and the instructions here can never disagree.

Important

Prefer the verification bundle. Fetching the script on its own gets you the script and nothing else -- and the suite needs the common/ folder beside it, which carries the DXP activation key and the Elasticsearch configuration. Without it LDM only warns:

⚠️  Global or local 'common/' folder not found. Some baseline assets may be missing.

The suite then runs to completion, exits 0 and reports success -- having never applied the activation key or the search configuration. Every assertion genuinely passes; they just tested a smaller system than the report claims (LDM-#1718).

The bundle also removes a second hazard in the commands above: LDM_REF is derived from whatever binary happens to be on PATH, so the script can come from a different release than the binary under test. The bundle is published per tag, so the pair is fixed by construction.

scripts/install_verification.sh (and install_verification.ps1 on Windows) does the whole staging sequence: fetches the bundle for a tag, verifies every checksum including its own, unpacks it so common/ sits beside the script, makes the suite executable, removes the archive once it is verified, and finds your activation key without being told where it is.

It is published as a release asset, so no checkout is needed -- which is the point: a release should be verifiable from its published artifacts alone, the way a user does it.

Run it from the directory that already holds your common/ folder, since that is where the activation key lives on each machine.

macOS / Linux

ldm system upgrade --beta          # take the release under test

LDM_TAG=v$(ldm version)
curl -fsSL -O "https://github.com/peterrichards-lr/liferay-docker-manager/releases/download/${LDM_TAG}/install_verification.sh"
chmod +x install_verification.sh
./install_verification.sh --tag "${LDM_TAG}" --no-binary --dir .

./verify_e2e_refactor.sh

Windows

ldm system upgrade --beta

# A downloaded .ps1 is blocked by default. Process-scoped, so it does not persist.
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass

$LdmTag = "v$(ldm version)"
Invoke-WebRequest -UseBasicParsing -OutFile install_verification.ps1 `
  "https://github.com/peterrichards-lr/liferay-docker-manager/releases/download/$LdmTag/install_verification.ps1"
.\install_verification.ps1 -Tag $LdmTag -NoBinary -Dir .

.\verify_e2e_refactor.ps1

Warning

On Windows, ldm system upgrade returns before it has finished -- it stages the swap and relaunches in a new window, because a running executable cannot replace itself. Run ldm version too promptly and it reports the old version, so $LdmTag stages the wrong release: the suite and the binary under test then come from different releases, which is exactly the pairing the per-tag bundle exists to guarantee. Confirm ldm version shows the version you expect before using it, or pin the tag literally (LDM-#1743). macOS and Linux upgrade in-process and are unaffected.

The flags that matter

Flag PowerShell Why
--tag -Tag the release to stage. Omit to take the latest.
--dir . -Dir . stage in place rather than in ./ldm-verification
--no-binary -NoBinary skip the binary -- you already have it from the upgrade
--activation-key -ActivationKey only needed when discovery cannot find your key
--no-self-check -NoSelfCheck skip verifying the installer against the release

--dir defaults to ldm-verification, created relative to where you run the script, which keeps your source common/ pristine and gives you one directory per release. --dir . stages into the current folder instead: one directory, no duplicated key, but each run overwrites the suite in place, so it stops being a record of any particular release.

Deriving the tag from ldm version

Safe only because the upgrade runs first -- it reads whatever binary is on PATH, so without the upgrade it stages the previous release. Subject to the Windows caveat above. Pin the tag literally if you are not upgrading first.

The activation key

The bundle cannot carry one (see below), so the installer looks for yours in two places, in order:

  1. the target's own common/ -- used in place, nothing copied
  2. ./common/ relative to where you ran the script -- copied in

It says which it found. If neither exists it warns loudly and continues, because offline staging is legitimate -- but heed it: without a key Liferay runs unlicensed, LDM only warns, and the suite still exits 0 reporting success, having verified a smaller system than it claims.

Discovery matches the filename pattern only, not the contents, so a placeholder file stages cleanly and then verifies an unlicensed DXP.

Or stage it by hand

LDM_TAG=vX.Y.Z
curl -fsSL -o verification-bundle.zip \
  "https://github.com/peterrichards-lr/liferay-docker-manager/releases/download/${LDM_TAG}/verification-bundle.zip"
unzip -o verification-bundle.zip -d ldm-verification
cd ldm-verification
shasum -a 256 -c SHA256SUMS
cp /path/to/activation-key-*.xml ./common/   # see the note below
bash verify_e2e_refactor.sh

Important

The bundle does not carry a DXP activation key, and cannot (LDM-#1733). common/activation-key-*.xml is gitignored -- it is licensed and must not be published -- so no CI checkout has one to package. Without it Liferay runs unlicensed, LDM only warns, and the suite still reports success: exactly the degraded-run-reported-as-pass the bundle exists to prevent, surviving in the one half that could not travel. Copy your own key into common/ before running. The bundle's MANIFEST.txt says which case it is.

$LdmVer = (ldm version 2>$null).Trim()
gh release download "v$LdmVer" --pattern verification-bundle.zip
Expand-Archive -Force verification-bundle.zip -DestinationPath ldm-verification
Set-Location ldm-verification
pwsh -File .\verify_e2e_refactor.ps1

The bundle contains both scripts, common/, the optional fragment-override harness, a MANIFEST.txt naming the tag it came from, and SHA256SUMS. Run the suite from the directory you unzip into, so common/ sits beside the script.

1. macOS & Linux

Run the Bash E2E verification script:

bash scripts/verify_e2e_refactor.sh

2. Windows

Run the PowerShell E2E verification script (ensure your PowerShell ExecutionPolicy permits running scripts):

powershell -ExecutionPolicy Bypass -File scripts/verify_e2e_refactor.ps1

Running only part of the suite (LDM_E2E_SECTIONS)

The full run is long, and for years the only thing that started it was release-e2e.yml on push: tags: ["v*"] β€” that is, after a version number had already been burnt. Every client-extension and routes regression of the 2.26 cycle (LDM-#1944, #1918, #1928, #1962, #1969) was therefore found by the suite meant to prevent it, one tag too late (LDM-#1975).

Both halves of the script accept LDM_E2E_SECTIONS, a comma-separated list of named sections. It defaults to all, so an unset variable behaves exactly as before.

Section What it covers
guardrails CLI refusals, compute targets, port-conflict diagnosis. No portal is booted for these.
boundary The portal's umask and the permissions of the config trees it publishes, plus the client-extension deploy, the services LDM generates for it, the routes tree it scaffolds, and the assertion that Liferay's own routes directories are the ones LDM mounted (LDM-#1944/#1918/#1928/#1923/#1573).
project The remaining assertions against the booted portal β€” hot deploy, snapshots, JSON schemas, exit codes, the reported access URL.
extras Portal patch overlay, non-ASCII naming, shared database and shared search. Each of these boots or rebuilds another stack.
LDM_E2E_SECTIONS=boundary bash scripts/verify_e2e_refactor.sh
$env:LDM_E2E_SECTIONS = "boundary"
powershell -ExecutionPolicy Bypass -File scripts/verify_e2e_refactor.ps1

Everything outside a section always runs, because every section needs it: the environment prep, the image pull, infra setup, provisioning and booting the test project, the teardown and the report. A slice is therefore not proportional to the checks it keeps β€” the Liferay boot is a floor no selection can get under. Measured against run 35862407660, the last green full run before this was added:

Phase Duration In a boundary slice
Prelude (venv, image pull, infra setup) 1m58s runs
guardrails 2m54s skipped
Provision + Liferay health wait 1m39s runs
project 3m17s skipped
boundary 53s runs
extras 11m34s skipped
Final status + teardown 5s runs
Total 22m20s ~4m35s, plus up to 180s for the LDM-#1944 routes-pairing wait added after that run

Two honesty guards come with it, and neither is optional:

  • An unknown section name is refused, not treated as "matches nothing". A typo that silently verified nothing would be the LDM-#1611 failure mode exactly β€” a green run that checked nothing.
  • A partial run writes Sections: … (PARTIAL RUN β€” not a full verification) into the report header, ends with E2E SECTIONS PASSED rather than ALL E2E VERIFICATIONS PASSED, and is not copied into references/verification-results/. That directory is what scripts/sync_compatibility.py builds the compatibility matrix from, and a slice must never enter it looking like a full run.

.github/workflows/pr-boundary-e2e.yml runs LDM_E2E_SECTIONS=boundary on every pull request to master that touches ldm_core/handlers/composer.py, ldm_core/workspace/**, ldm_core/pipelines/** or the suite itself. That is the whole point of the mechanism: boundary regressions are now discovered before the tag instead of after it.

Declaring which environment a run represents

scripts/sync_compatibility.py builds the compatibility matrix from the report's content, and it recognises only Fedora and Ubuntu by name. Every other Linux distro used to fall through to a generic "Linux Workstation / Linux", so Debian, Rocky and Alpine all produced the same canonical report name and each silently overwrote the previous β€” three genuine CI passes went in and one row came out, labelled as none of them (LDM-#1614).

Set LDM_ENV_LABEL so the run declares its own identity. It becomes an Env Label: line in the report, which is what survives into the committed record:

LDM_ENV_LABEL=debian bash scripts/verify_e2e_refactor.sh
$env:LDM_ENV_LABEL = "debian"
powershell -ExecutionPolicy Bypass -File scripts/verify_e2e_refactor.ps1

The label names the distro; the version still comes from the report's own Platform: line, so debian on Debian 12 becomes a Debian 12 row. The verify-linux CI matrix passes its matrix.distro key automatically, so all five of its arms are distinguished without anything to remember. Leaving the variable unset keeps the previous behaviour exactly, and Fedora and Ubuntu are unaffected either way.

The label also settles the matrix's Docker Provider column. That was derived by looking for the literal word linux in the Platform: line, and every containerised arm satisfied it only by luck β€” Fedora Linux 44 (Container Image), Rocky Linux 9.3 (Blue Onyx), Alpine Linux v3.24, Debian GNU/Linux 12 (bookworm). Upstream shortening any of those (Alpine to Alpine 3.25, say) would have published a user-facing row reading provider Unknown and renamed the canonical report …-unknown-pass.txt. A declared label is now sufficient on its own, so the column no longer depends on a vendor's PRETTY_NAME wording (LDM-#1631). The platform-string match remains as the fallback for a hand-run with no LDM_ENV_LABEL set, and a run without a label is never refused.

Should two reports still resolve to one environment name from genuinely different environments, sync_compatibility.py refuses and exits non-zero before moving anything, naming each report and its platform β€” the same approach LDM-#1390 took to a version mismatch. Repeat runs of the same environment continue to supersede one another as before.

A blank header field reads as Unknown, not as the next line

Every header field is parsed with a pattern bounded to its own line. That is worth stating because it was not always true: the patterns used \s, which matches a newline, so a blank field skipped the line break and captured the following header line (LDM-#1633).

It was not hypothetical. Until LDM-#1639 verify_e2e_refactor.ps1 did not populate Platform: on PowerShell 5.1 β€” $PSVersionTable.OS does not exist there β€” so the committed PowerShell 5.1 report recorded its platform as PowerShell: 5.1.22621.6133 (Desktop), the line beneath it. Across the archive, 19 reports recorded a platform of Binary: …\ldm.exe and one recorded a Docker engine version of running.

The canonical names and matrix rows were unaffected β€” the filename and the declared label carry the identity β€” but the collision guard above compares the identity each report states about itself, and that comparison must not be fed a string belonging to a different line.

A field that is present but empty now reports Unknown (or nothing at all, where the caller has a fallback to fall through to, as Docker: does to the doctor section).

The report-side half is closed too. verify_e2e_refactor.ps1 populated Platform: from $PSVersionTable.OS, which arrived in PowerShell 6, so on Windows PowerShell 5.1 the field was blank at source. It now falls back to [System.Environment]::OSVersion.VersionString, which exists on 5.1 and 7 alike and needs no CIM/WMI call (LDM-#1639). Reports committed before that change still carry a blank field, and read as Unknown β€” accurate, where the previous value was not.

How a CI pass reaches the published matrix

On a stable tag push, the sync-compatibility job downloads the verification artifacts, copies the passing Linux reports into references/verification-results/, re-runs sync_compatibility.py and opens a docs-only PR. Being labelled is therefore necessary but not sufficient β€” a report also has to be fetched, and for a while three were not.

That job downloaded two artifacts by name, verification-results-ubuntu and verification-results-fedora, and its pre-copy cleanup knew the matching two slugs, while verify-linux ran five arms. Debian, Rocky and Alpine ran on every stable tag, passed, uploaded a report β€” and had it discarded by the job whose purpose is to publish it. Nothing went red, because a list that has drifted out of step with another list looks exactly like a list that has not (LDM-#1625).

It now fetches verification-results-* in a single step and selects what to publish by the linux-workstation- prefix every verify-linux report carries, so adding a distro to matrix.distro needs no change to the sync job at all. Two consequences worth knowing:

  • macOS and Windows artifacts are downloaded but never published. Those rows are curated by hand, and the filename selector β€” not the download step β€” is what keeps them that way.
  • If nothing passing was downloaded, the step fails rather than clearing the committed Linux reports and replacing them with nothing.

The slug is derived from the report, never from the matrix key, and the three differ: the rockylinux arm reports Rocky Linux 9.3 (Blue Onyx), writes a raw file named …rocky-9.3…, and publishes as verify-linux-workstation-rocky-linux-9.3-native-docker-pass.txt. Read the canonical name out of a real report (or out of sync_compatibility.get_report_metadata) rather than guessing it.

What the scripts verify

  • Docker daemon connectivity and registry cleanups.
  • Full project initialization, compose generation, and database sidecar startup.
  • Automated snapshot extraction, integrity verification (SHA-256 signature generation), and directory structure restores.
  • Metadata namespacing and port collision handling (confirming the ldm fork command works cleanly without conflicts).
  • Up-front announcement of a project targeting a remote compute node, and its deliberate suppression under --json (LDM-#1341 / #1093). Provoked with an RFC 5737 TEST-NET-1 target whose Docker context is then deleted, so the project is classified remote without anything ever opening an SSH connection.
  • Late port-conflict handling: exit code 4 plus guidance naming the port a re-run would pick, rather than telling you to go stop a process (LDM-#1350).
  • Teardown of resources and network isolation.

What the scripts cannot verify

Some behaviour is only reachable through a dependency the scripts can neither create nor clean up. Extending E2E coverage onto one of those would produce a check that reports a third party's state rather than LDM's, so the coverage lives in the unit suite instead. Recorded here so the gap stays visible:

  • A package manifest that under-reports being corrected from its payload, on the GitHub-release input (LDM-#1579). _import_ldm_package is reached from exactly one input: a GitHub repo URL whose latest release carries a .ldmp asset. The API host is hardcoded (api.github.com), so an end-to-end exercise of that input would mean publishing a deliberately malformed package to a real release (LDM-#1588).

Covered instead by ldm_core/tests/test_package_listing_recovery.py, which drives the real download, checksum, extraction, verification and hydration over a .ldmp built in the test with only requests.get mocked, and which measures the routing above rather than asserting it in prose.

The manifest PARSE refusal on a local .ldmp is no longer in this category, and is now asserted E2E. LDM-#1621 put manifest verification on the import pipeline, and the parse refusal fires in PackageVerificationStage before ProjectSetupStage creates anything -- so it needs no network, no published release and no Liferay boot. Both scripts build the package themselves and assert the refusal: verify_ldmp_manifest_refusal() in the .sh, Test-LdmpManifestRefusal in the .ps1.

Both assert the exit code and the reason, which is not belt-and-braces. Measured on 2026-09-07 against a binary without #1621: the same package reached verify_runtime_environment and exited 1 on FATAL: VOLUME MOUNTING IS BROKEN. A check that looked only at the exit code would have reported that as a pass.

What is still not asserted E2E is the recovery and the origin reporting. Both continue past the manifest into cmd_restore, so exercising them needs a real database restore and a Liferay boot against a package the script would have to build from a running project. Covered by ldm_core/tests/test_local_package_verification.py, which drives the real cmd_import through the real pipeline over a package built in the test, with only cmd_restore, cmd_run and the shared doctor preflight stubbed.

The two script functions are named, rather than inlined, so their own assertion logic is testable without Docker or an ldm binary: ldm_core/tests/test_verify_scripts.py drives each half against a stub ldm that returns a chosen exit code and message, and checks the parity of the two halves.

Exit status, and why CI cross-checks the report

Both scripts exit 0 only when the run reached ALL E2E VERIFICATIONS PASSED, and 1 otherwise. The Bash script has always done this (set -e plus explicit exit 1 on each failure path). The PowerShell script did not until LDM-#1611: its top-level catch wrote the -fail report, printed [FAILED] Verification FAILED (fail), and then fell off the end of the script β€” and PowerShell exits 0 once an exception has been handled. Measured on pwsh 7.6.5, pwsh script.ps1 and pwsh -File script.ps1 both exited 0 from that failure path.

The consequence was that LDM Platform Verification (Multi-OS) reported success on the v2.21.0-pre.2 and v2.21.0 tag runs while uploading three -fail.txt reports (both Windows shells and macOS/Colima; the macOS half came from a continue-on-error: true on that job's suite step). A job that cannot report failure is worse than an absent one, because the compatibility matrix is built from its output: a red job says "unverified", whereas those green ones said "verified" and were wrong.

Every verification job therefore now cross-checks the artefact as a second, independent signal β€” the exit status alone has been lost once already. The step fails if no verify-*.txt was produced, if a report's filename ends -fail, or if a report lacks the ALL E2E VERIFICATIONS PASSED marker. It runs with if: always(), after the artefact upload, so a failing run still ships its report.

The .ps1's exit status is regression-tested by executing it β€” TestPowerShellSuiteExitStatus in ldm_core/tests/test_verify_scripts.py extracts the script's real try/catch/finally tail and epilogue, stubs only the try body (the part needing Docker), and asserts the exit code in real PowerShell for a failing run, a passing run and the "no branch recorded an outcome" case. A source-text match was rejected deliberately: it passes whenever the text survives, including when the behaviour is gone, which on this issue of all issues would repeat the mistake.

Report size is a usable smoke signal alongside this: a genuine pass is ~4 kB, and the three false passes above were 608–794 b.

Which platforms CI actually verifies (LDM-#1662)

Making those jobs honest revealed what they had been hiding: they had never passed. The red that followed PR #1622 was not a regression β€” it was the true state arriving. The runner images were identical on the last "green" run (34063433007, v2.21.0) and the first red one, so nothing about the environment changed; only the reporting did.

The arms were therefore split:

Workflow Arms May be red?
LDM Platform Verification (Multi-OS) verify-linux (ubuntu, fedora, rockylinux, alpine, debian) No β€” it publishes the matrix
LDM Platform Verification (Best Effort) verify-macos (Colima) Yes β€” diagnostic only

The Windows arms were deleted, not demoted. GitHub-hosted Windows runners run Windows containers, and every LDM image is a Linux image, so the job failed at no matching manifest for windows(10.0.26100)/amd64 and then at docker network create liferay-net. No workflow-level change makes that pass. Windows coverage is the manual PowerShell 5.1 / 7 / WSL2 runs in the table at the top of this document β€” which is where it always genuinely came from, since sync-compatibility has only ever published reports carrying the linux-workstation- prefix.

verify-macos is expected to be red for now. Colima does not start on macos-26-arm64, so the suite finds no daemon at unix:///var/run/docker.sock. It is kept running because the report and debug logs it uploads are the only standing signal on that platform, and because the failure is plausibly recoverable in a way the Windows one is not.

Do not "fix" a red best-effort arm with continue-on-error: true on the job. That makes it report success, which is precisely the LDM-#1611 defect described above. Best effort means red in a workflow allowed to be red, never green in one that is not. test_ci_verification_reporting.py asserts this, along with the moved job keeping its report cross-check and not drifting back into the Linux workflow.

(The continue-on-error on the macOS Start Colima step is unrelated and deliberate: it lets the suite step fail on its own and produce a report plus debug logs, instead of aborting the job on a bare brew/colima trace.)

Fragment override: a CI job, deliberately not a script check (LDM-#1745)

The fragment-override chain β€” LDM's most intricate runtime feature β€” had no test that exercised it end to end. verify_e2e_refactor.{sh,ps1} contain zero fragment assertions, and the unit suite mocks both Docker and Liferay, so nothing had ever demonstrated the thing users care about: a configured override reaches the rendered page.

It is now measured by .github/workflows/fragment-override.yml, on v* tags and workflow_dispatch, mirroring release-e2e.yml's shape. The job runs scripts/fragment_override_harness.py, which builds a site-initializer client extension, boots a licensed DXP, lets the Site Initializer place the fragment on a page, has LDM apply the override, restarts, and asserts four observations in order: the default value in fragmententrylink.editablevalues, the default value in the rendered HTML, the overridden value in editablevalues, and the overridden value in the rendered HTML with the default gone. The first two exist so the last two cannot pass vacuously.

It stays out of the verification scripts on purpose. Fragment override is platform-independent β€” LDM talking to Liferay's API and database, with nothing that varies by host OS β€” so running it across macOS, Windows and Linux would cost three Liferay boots for one bit of information. It also depends on a Liferay boot and on Site Initializer population, which are durations the suite does not own: the principle LDM-#1383 set out, LDM-#1444 applied, and LDM-#1728 was a reminder of. test_fragment_override_harness.py asserts both halves of that decision β€” that neither script invokes the harness, and that the workflow exists.

It needs a licensed DXP, which is why this one job takes a repository secret. An unlicensed portal serves the DXP Activation page in place of the site, so the two rendered-page observations β€” the only ones that answer the question β€” cannot be made at all. The job reads LIFERAY_ACTIVATION_KEY_XML (the XML text of a developer activation key, which carries no machine binding and so works on an ephemeral runner), writes it to a file at run time, and hands the path to the harness. The job fails loudly when the secret is absent rather than running and reporting a weaker result as a pass. Never commit an activation key.

Measured budget. A full run on a local Apple-silicon Colima VM (4 CPU, 8 GiB) against DXP 2026.q3.0 with the image already pulled took 341 s:

Step Seconds
ldm import 18.9
ldm run (first Liferay startup, 110.7 s of it) 188.5
Headless answering 8.2
Site Initializer placing the fragment 10.1
ldm wait applying the override 71.0
ldm restart (second startup is warm β€” 10.9 s) 2.8
Rendered page carrying the new value 0.3
Total 341.1

Two of those are worth knowing. The override step is 71 s because LDM spends its whole --fragment-patch-timeout waiting for two Headless rungs that are known dead on a published site-initializer page before falling through to the database fallback; the harness passes 60 rather than accept the 300 s default, which is otherwise pure wall clock. And the restart is cheap because the OSGi state and database are warm β€” 10.9 s against 110.7 s for the cold boot.

A GitHub ubuntu-latest runner is slower than this VM and a first run also pays the ~5 GB DXP image pull, so the job's ceiling is 90 minutes. Do not treat the table above as the CI budget β€” the workflow prints its own timings from report.json on every run, which is the number to read.


Last Updated: 2026-10-15 | Last Reviewed: 2026-10-15