Skip to content

Developing localghost

Repository layout

  • compose.yaml is the stable public interface and must remain self-contained.
  • examples/compose.yaml supplies primary, secondary, and unlabelled fixtures.
  • scripts/integration-test.sh exercises the compatibility contract.
  • src/localghost/ contains the packaged Click CLI, bundled hub Compose definition, override generator, and source-loaded Traefik provider.
  • pyproject.toml and uv.lock define the build and locked development environment.
  • .github/workflows/ci.yml validates the project on Linux with Docker Compose 5.1.4, which is the fixed CI baseline rather than a required user-side patch version.
  • .github/dependabot.yml proposes reviewed Python, Traefik, and GitHub Actions updates. The pinned Caddy host-bridge image lives in generator.py, so review it explicitly during dependency maintenance.

The hub Compose file must not gain application-specific mounts, state, or configuration files. Consumers rely on the fixed localghost project and localghost network names.

Static validation

Resolve and validate both Compose files:

LOCALGHOST_IMAGE_TAG="v$(uv run localghost --version)" \
  docker compose -f compose.yaml config --quiet
COMPOSE_PROJECT_NAME=localghost-fixture-a \
  docker compose -f examples/compose.yaml config --quiet
bash -n scripts/integration-test.sh
uv run ruff check .
uv run pytest
(cd src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca \
  && go test ./...)

The pytest suite covers generator logic in isolation and exercises the CLI against resolved Compose models. It validates generated Compose, Dockerfile, host-bridge, extension, backup, and refusal behavior.

Review the fully rendered fixture configuration when changing interpolated labels:

COMPOSE_PROJECT_NAME=localghost-fixture-a \
  docker compose -f examples/compose.yaml config

Integration suite

Run:

./scripts/integration-test.sh

The suite is destructive only to Docker resources named localghost, localghost-fixture-a, localghost-fixture-b, localghost-fixture-host, and localghost-fixture-dockerfile. It refuses to begin if any of those resources already exist and cleans up resources it creates even after failure. Ports 80, 18080, 18443, and 19090 must be available.

It also destroys the CA volumes. Teardown runs with --volumes, and the volumes holding the bootstrapped root are named localghost like everything else. On a machine with trusted HTTPS configured that leaves the hub unable to start — it crash-loops on a missing bootstrapped root — until localghost trust install runs again. The suite therefore refuses to begin in that state. Either point LOCALGHOST_STATE_DIR at a scratch directory, which leaves your own trust configuration untouched, or set LOCALGHOST_ACCEPT_CA_RESET=1 to accept the reset and re-bootstrap afterwards.

Coverage includes:

  • validation and hub-first external-network failure;
  • repeated idempotent hub startup and container health;
  • exact loopback port publication and absence of raw API port publication;
  • two concurrent fixture projects with isolated primary routes;
  • secondary-service routing and rejection of an unlabelled container;
  • explicit backend-port selection when another port is exposed;
  • generated bridging to an HTTP application running directly on the host;
  • trusted HTTPS routing with the bootstrapped public root, without changing the host trust store;
  • dashboard root redirection and internal dashboard access;
  • removal of one application without affecting another or the hub;
  • hub restart and forced reconciliation without consumer recreation; and
  • recreation on a non-default loopback port.

Override test names or ports only when necessary:

TEST_DEFAULT_PORT=18081 \
TEST_ALTERNATE_PORT=18082 \
TEST_HTTPS_PORT=18444 \
./scripts/integration-test.sh

LOCALGHOST_STATE_DIR and LOCALGHOST_ACCEPT_CA_RESET control the trust guard described above.

CI should retain the default port-80 run because loopback publication on the public default is part of the release contract.

Documentation

Documentation is built with Zensical. Preview it locally with:

uv run zensical serve

Build the static site with:

uv run --frozen zensical build

Build the CLI package

Build both the source distribution and wheel:

uv build --no-sources

Test the local package through the same isolated tool mechanism used after PyPI publication:

uvx --from . localghost --help
uvx --from . localghost hub down --help
uvx --from . localghost save --help
uvx --from . localghost run --help

Release-candidate test

The checked-in source test does not prove that the packaged CLI contains the hub definition it starts. Before release, build a candidate wheel, then run the lifecycle commands from that wheel:

uv build --no-sources
wheel=$(find dist -maxdepth 1 -name '*.whl' -print -quit)
uvx --isolated --from "$wheel" localghost hub up
uvx --isolated --from "$wheel" localghost hub down

CI performs the same wheel smoke test before the source integration suite.

PyPI authentication

For releases from GitHub Actions, use PyPI Trusted Publishing (recommended). After configuring the repository, workflow, and environment in PyPI, uv publish --trusted-publishing always obtains a short-lived credential through GitHub Actions OIDC; no long-lived PyPI token is stored in GitHub.

For a manual publish, provide a project-scoped PyPI API token to uv:

UV_PUBLISH_TOKEN=pypi-xxxxxxxx uv publish

uv auth login https://upload.pypi.org/legacy/ is an alternative for storing the token in uv's credential store. Do not commit tokens or credentials. PyPI does not support account username-and-password uploads; use an API token.

Release checklist

  1. Review dependency changes and security implications, including the Traefik hub image and generated Caddy host-bridge image.
  2. Run static validation and the local integration suite.
  3. Pilot two independent checkouts with unique Compose project names (e.g. two checkouts of the same application).
  4. Update CHANGELOG.md and pyproject.toml version, and all example version tags.
  5. Make a trial build with uv build --no-sources, test the resulting wheel with uvx, and exercise the lifecycle commands from the release commit in CI. Push that exact commit to main, then wait for its CI run to succeed. When documentation changed, also wait for the GitHub Pages deployment to succeed:
    gh run list --commit "$(git rev-parse HEAD)"
    gh run watch <run-id>
    
  6. After those workflows pass, use a clean checkout of that exact commit to build artifacts for the GitHub release, smoke-test the wheel, and record both checksums. PyPI receives the workflow's own build rather than these, so treat the checksums as a record of what was attached to the release:
    test -z "$(git status --porcelain)"
    rm -rf dist
    uv build --no-sources
    wheel=$(find dist -maxdepth 1 -name '*.whl' -print -quit)
    sdist=$(find dist -maxdepth 1 -name '*.tar.gz' -print -quit)
    test -n "$wheel" && test -n "$sdist"
    uvx --isolated --from "$wheel" localghost hub up
    uvx --isolated --from "$wheel" localghost hub down
    sha256sum "$wheel" "$sdist"
    
  7. Create the immutable SemVer tag and a draft GitHub release for that commit, attaching the exact artifacts from step 6. Patch-release notes should be short and front-facing: one summary sentence, 3–6 practical highlights, and an uvx --refresh localghost upgrade command. Reserve a full introduction and CLI reference for major releases.
    git tag -a v<version> -m "v<version>"
    git push origin v<version>
    gh release create v<version> "$wheel" "$sdist" \
      --draft --title "localghost v<version>" --notes-file <release-notes.md>
    
  8. Pushing the tag publishes to PyPI: the publish job in ci.yml runs on any v* tag once the integration job passes, builds on a clean checkout, and uploads through Trusted Publishing from the pypi environment. Watch that run rather than uploading by hand, and note that it rebuilds, so the files on PyPI are not byte-identical to step 6's:
    gh run list --commit "$(git rev-parse v<version>)"
    gh run watch <run-id>
    
    Publish by hand only if that workflow is unavailable:
    uv publish "$wheel" "$sdist"
    
  9. Wait for the published version to resolve from PyPI, then refresh and verify its lifecycle. Confirm a refreshed unpinned resolution selects the same version:
    uvx --refresh localghost@<version> --version
    uvx --refresh localghost@<version> hub up
    uvx localghost@<version> hub down
    uvx --refresh localghost --version
    
    PyPI propagation can take a short time; retry the exact-version check until it resolves rather than assuming an upload is immediately available.
  10. Publish the GitHub release draft only after the PyPI verification succeeds:
    gh release edit v<version> --draft=false
    

Consumer-visible changes to fixed names, labels, hostname conventions, or lifecycle commands require a major version. Additive compatible features may be minor releases; compatible fixes, reviewed image updates, and documentation updates may be patch releases.