fix(docker): drop COPY of gitignored data/ so staging image builds from a worktree
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 16s

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>
This commit is contained in:
2026-06-07 13:39:02 +00:00
parent e18947d2d9
commit 01684a89df
5 changed files with 110 additions and 1 deletions

View File

@@ -0,0 +1,90 @@
"""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."
)