"""ORCH-061 regression: the image must build from a git WORKTREE context. The staging-image rebuild of ORCH-058 (``check_staging_image_fresh`` / the deploy hook's ``--build-staging`` mode) uses the task **worktree** as the ``docker build`` context. A git worktree only contains git-TRACKED files, so any ``COPY`` of a gitignored path makes ``docker build`` fail (rc=1) -> ``deploy-staging`` rolls back to ``development`` (the exact loop ORCH-061 fixes). The concrete regression: ``COPY data/ ./data/`` referenced ``data/`` which is gitignored (runtime SQLite DB + backups) and therefore absent in every worktree. At runtime ``data/`` always arrives via the compose bind mount (``./data:/app/data`` / ``./data/staging:/app/data``), so baking it in was both build-breaking and pointless. These tests guard the invariant statically (no docker required): the Dockerfile must not ``COPY`` a path that ``.gitignore`` excludes. """ import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] DOCKERFILE = REPO_ROOT / "Dockerfile" GITIGNORE = REPO_ROOT / ".gitignore" def _dockerfile_copy_sources() -> list[str]: """Source paths from every ``COPY `` line in the Dockerfile. ``--from`` (multi-stage / build-context) COPYs are skipped — they do not read the worktree build context. The last token on a COPY line is the destination. """ sources: list[str] = [] for raw in DOCKERFILE.read_text().splitlines(): line = raw.strip() if not line.upper().startswith("COPY "): continue if "--from" in line: continue tokens = line.split()[1:] # drop the COPY keyword tokens = [t for t in tokens if not t.startswith("--")] if len(tokens) >= 2: sources.extend(tokens[:-1]) # all but the destination return sources def _gitignored_dirs() -> set[str]: """Top-level directory names excluded by ``.gitignore`` (e.g. ``data``).""" dirs: set[str] = set() for raw in GITIGNORE.read_text().splitlines(): entry = raw.strip() if not entry or entry.startswith("#"): continue entry = entry.rstrip("/") # only care about simple top-level dir patterns (no globs / nested paths) if entry and "/" not in entry and "*" not in entry: dirs.add(entry) return dirs def test_dockerfile_does_not_copy_gitignored_data(): """``data/`` (gitignored runtime dir) must never be a Dockerfile COPY source.""" copy_sources = _dockerfile_copy_sources() offending = [s for s in copy_sources if s.rstrip("/") == "data"] assert not offending, ( "Dockerfile COPYs gitignored 'data/' -> build fails from a worktree " f"context (rc=1). Offending COPY sources: {offending}. " "Use `RUN mkdir -p /app/data` and rely on the compose bind mount instead." ) def test_dockerfile_copies_only_git_tracked_sources(): """No Dockerfile COPY source may be a gitignored top-level directory.""" gitignored = _gitignored_dirs() copy_sources = [s.rstrip("/") for s in _dockerfile_copy_sources()] leaking = sorted(set(copy_sources) & gitignored) assert not leaking, ( "Dockerfile COPYs gitignored path(s) absent from git worktrees: " f"{leaking}. The staging rebuild (ORCH-058) builds from the worktree and " "will fail (rc=1)." ) def test_data_dir_mount_target_is_created(): """The image must create the /app/data mount target (no COPY dependency).""" text = DOCKERFILE.read_text() assert re.search(r"mkdir\s+-p\s+/app/data", text), ( "Dockerfile must `RUN mkdir -p /app/data` so the compose bind-mount " "target exists without depending on a (gitignored) host data/ dir." )