2828
2929@dataclass (frozen = True )
3030class BuildProvenance :
31- """Source identity for one build. All fields degrade to ``None``.
31+ """Source identity for one build; every field degrades to ``None``.
3232
33- Exactly one identity anchors the build: a **clean committed tree** keys on
34- ``commit`` (``working_tree_hash`` is ``None``); anything else — a dirty tree
35- or a non- git context, neither of which a commit can address — carries a
36- ``working_tree_hash `` instead. So a non-null hash means "no clean commit to
37- point to," and ``is_clean_commit `` is the gate ``--require-clean`` checks .
33+ ``working_tree_hash`` is the deterministic content hash of the build context
34+ and is always computed — it identifies the exact bytes that shipped,
35+ independent of git state. ``commit`` (+ ``ref`` / ``repo``) anchor those bytes
36+ to source, and ``dirty `` records whether the work tree had uncommitted changes
37+ at build time (``None `` outside a git work tree) .
3838 """
3939
4040 repo : Optional [str ] = None
4141 commit : Optional [str ] = None
4242 ref : Optional [str ] = None
4343 subpath : Optional [str ] = None
4444 working_tree_hash : Optional [str ] = None
45+ dirty : Optional [bool ] = None
4546 author_name : Optional [str ] = None
4647 author_email : Optional [str ] = None
4748 build_timestamp : Optional [str ] = None
4849
49- @property
50- def is_clean_commit (self ) -> bool :
51- return self .commit is not None and self .working_tree_hash is None
52-
53- def source_fields (self ) -> dict [str , str ]:
50+ def source_fields (self ) -> dict [str , object ]:
5451 """The ``source_*`` form fields for the cloud-build upload (None omitted)."""
5552 fields = {
5653 "source_repo" : self .repo ,
5754 "source_commit" : self .commit ,
5855 "source_ref" : self .ref ,
5956 "source_subpath" : self .subpath ,
6057 "working_tree_hash" : self .working_tree_hash ,
58+ "source_dirty" : self .dirty ,
6159 }
6260 return {key : value for key , value in fields .items () if value is not None }
6361
64- def build_info (self ) -> dict [str , str ]:
62+ def build_info (self ) -> dict [str , object ]:
6563 """The ``build-info.json`` payload (runtime ``registration_metadata``).
6664
6765 Overlapping keys match the server's ``DeploymentHistory`` type
@@ -75,6 +73,7 @@ def build_info(self) -> dict[str, str]:
7573 "branch_name" : self .ref ,
7674 "subpath" : self .subpath ,
7775 "working_tree_hash" : self .working_tree_hash ,
76+ "dirty" : self .dirty ,
7877 "author_name" : self .author_name ,
7978 "author_email" : self .author_email ,
8079 "build_timestamp" : self .build_timestamp ,
@@ -167,6 +166,19 @@ def working_tree_hash(root: Path) -> str:
167166 return hashlib .sha256 ("\n " .join (lines ).encode ("utf-8" )).hexdigest ()
168167
169168
169+ def _safe_working_tree_hash (root : Path ) -> Optional [str ]:
170+ """``working_tree_hash`` that degrades to None — capture must never fail a build.
171+
172+ A permission error or filesystem race during the walk/stat/read would otherwise
173+ raise out of capture and abort the build before the archive is even created.
174+ """
175+ try :
176+ return working_tree_hash (root )
177+ except Exception :
178+ logger .warning ("build-provenance: content hash failed; omitting" , exc_info = True )
179+ return None
180+
181+
170182def capture_build_provenance (
171183 repo_path : Path , context_root : Path , content_root : Optional [Path ] = None
172184) -> BuildProvenance :
@@ -176,19 +188,18 @@ def capture_build_provenance(
176188 relative to the repo root (which agent, in a monorepo). ``content_root`` is
177189 the directory hashed — the *staged*, post-``.dockerignore`` tree that actually
178190 ships; it defaults to ``context_root`` when there is no separate staging dir.
179- The content hash is computed unless a clean commit identifies the build (so:
180- for a dirty tree or a non-git context, but not for a clean committed tree) .
191+ ``working_tree_hash`` is always computed; git coordinates anchor it to source
192+ when available .
181193 """
182194 timestamp = datetime .now (timezone .utc ).isoformat ()
183195 hash_root = content_root if content_root is not None else context_root
196+ tree_hash = _safe_working_tree_hash (hash_root )
197+
184198 repo_root = _git (repo_path , "rev-parse" , "--show-toplevel" )
185199 if repo_root is None :
186- # No git at all — the content hash is the only identity available.
187- logger .info ("build-provenance: %s is not a git work tree; hashing context" , repo_path )
188- return BuildProvenance (
189- working_tree_hash = working_tree_hash (hash_root ),
190- build_timestamp = timestamp ,
191- )
200+ # No git — the content hash is the only identity available.
201+ logger .info ("build-provenance: %s is not a git work tree; content hash only" , repo_path )
202+ return BuildProvenance (working_tree_hash = tree_hash , build_timestamp = timestamp )
192203
193204 repo_root_path = Path (repo_root )
194205 commit = _git (repo_root_path , "rev-parse" , "HEAD" )
@@ -207,17 +218,16 @@ def capture_build_provenance(
207218 except ValueError :
208219 subpath = None
209220
210- # Hash unless a clean commit identifies the build: dirty tree, or an unborn
211- # HEAD with no commit yet, both fall back to the content hash.
221+ # `git status --porcelain` is empty (→ _git returns None) for a clean tree.
212222 dirty = _git (repo_root_path , "status" , "--porcelain" ) is not None
213- tree_hash = working_tree_hash (hash_root ) if (dirty or commit is None ) else None
214223
215224 return BuildProvenance (
216225 repo = remote ,
217226 commit = commit ,
218227 ref = ref ,
219228 subpath = subpath ,
220229 working_tree_hash = tree_hash ,
230+ dirty = dirty ,
221231 author_name = author_name ,
222232 author_email = author_email ,
223233 build_timestamp = timestamp ,
0 commit comments