The staging-image rebuild (check_staging_image_fresh, ORCH-058) uses the task git worktree as the docker build context. `data/` is gitignored (runtime SQLite DB + backups) so it is absent in every worktree -> `COPY data/ ./data/` failed the build (rc=1) -> deploy-staging rolled back to development (the loop ORCH-061 targets, surfaced one step later once the C9a/C9b waiver let the pipeline reach the rebuild). The DB always arrives via the compose bind mount, so baking it in was pointless (and leaked a stale host DB into the image). Replace `COPY data/ ./data/` with `RUN mkdir -p /app/data` and add a static regression guard asserting the Dockerfile never COPYs a gitignored path. Refs: ORCH-061 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
91 lines
3.7 KiB
Python
91 lines
3.7 KiB
Python
"""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 <src...> <dst>`` 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."
|
|
)
|