From 238079da66f75785d8c3c55273f7be4de6cddc4f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 21:43:37 -0700 Subject: [PATCH] Let the image build see the dependency Cargo patches The rename pass vendored a patched sieve-rs and pointed Cargo.toml's [patch.crates-io] at vendor/sieve-rs. .dockerignore ignores everything and re-includes a short list that did not have vendor on it, so the image build had no such directory and stopped at failed to load source for dependency `sieve-rs` failed to read /build/vendor/sieve-rs/Cargo.toml CI could not have caught that: it builds from a checkout, where the directory is simply there, and only the image build has a context to prune. The first that was known about it was a tag that had already been pushed. So: vendor is re-included, and tools/fork/context-check.py now asserts the thing that was quietly assumed -- every path a [patch] section names exists and survives .dockerignore. It runs beside the other fork checks and takes no toolchain. Also, the comments in .dockerignore started with // , which Docker does not read as a comment: they were patterns that happened to match nothing. They are # now. --- .dockerignore | 10 ++++- .gitea/workflows/ci.yml | 5 +++ tools/fork/context-check.py | 82 +++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100755 tools/fork/context-check.py diff --git a/.dockerignore b/.dockerignore index 2778083..3fd2176 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,16 @@ -// Ignore everything +# Ignore everything * -// Allow what is needed +# Allow what is needed !crates !tests !resources +# The patched dependency Cargo.toml's [patch.crates-io] points at. Without +# it the build context has no vendor/, and `cargo chef cook` fails on +# "failed to load source for dependency sieve-rs" -- which CI cannot see, +# because CI builds from a checkout and only the image build has a context. +!vendor + !Cargo.lock !Cargo.toml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c0e514f..e3541c9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -35,6 +35,11 @@ jobs: - run: python3 tools/fork/name-check.py - if: always() run: python3 tools/fork/notice-check.py + # Cargo can patch a dependency to a directory in this repository, and + # the image builds from a context .dockerignore prunes to almost + # nothing. CI never sees the difference; a release does. + - if: always() + run: python3 tools/fork/context-check.py build: # Either runner (host1 or host2): the build needs no docker socket. diff --git a/tools/fork/context-check.py b/tools/fork/context-check.py new file mode 100755 index 0000000..1107424 --- /dev/null +++ b/tools/fork/context-check.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Coffey Labs +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Every path Cargo patches has to be in the image's build context. + +Cargo.toml's [patch.crates-io] can point at a directory in this repository, +and the Dockerfile builds from a context that .dockerignore prunes to almost +nothing. Those two facts met on 2026-09-23: a vendored, patched sieve-rs +landed, CI stayed green -- it builds from a checkout, where the directory is +simply there -- and the release build failed on + + failed to load source for dependency `sieve-rs` + failed to read /build/vendor/sieve-rs/Cargo.toml + +after a tag had already been pushed. This is seconds, and it runs beside the +other fork checks rather than waiting for a release to find out. +""" + +import re +import sys +from pathlib import Path + +root = Path(__file__).resolve().parents[2] + + +def patched_paths(manifest: Path) -> list[str]: + """Directories named by a [patch...] section's `path = "..."` entries.""" + out, in_patch = [], False + for line in manifest.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("["): + in_patch = stripped.startswith("[patch") + continue + if not in_patch: + continue + m = re.search(r'path\s*=\s*"([^"]+)"', stripped) + if m: + out.append(m.group(1)) + return out + + +def allowed(dockerignore: Path) -> set[str]: + """The first path segment of every re-inclusion rule.""" + keep = set() + for line in dockerignore.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("!"): + keep.add(stripped[1:].strip("/").split("/")[0]) + return keep + + +def main() -> int: + paths = patched_paths(root / "Cargo.toml") + if not paths: + print("no patched paths to check") + return 0 + keep = allowed(root / ".dockerignore") + bad = [] + for p in paths: + top = p.strip("/").split("/")[0] + if top not in keep: + bad.append((p, top)) + elif not (root / p).is_dir(): + bad.append((p, None)) + for path, top in bad: + if top is None: + print(f"Cargo.toml patches {path}, which does not exist", file=sys.stderr) + else: + print( + f"Cargo.toml patches {path}, but .dockerignore does not re-include {top!r}:\n" + f" the image build would not see it, and cargo would fail on it.\n" + f" Add `!{top}` to .dockerignore.", + file=sys.stderr, + ) + if bad: + return 1 + print(f"build context includes every patched path: {', '.join(paths)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())