blob: 6ef98033ca35eac71ada412359bdcf62670dd328 [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""SHAC check requiring README.fuchsia to be updated alongside third_party changes."""
_README_NAME = "README.fuchsia"
# Commit message footer that waives the check. A reason must follow the colon.
_SKIP_FOOTER = "Skip-Readme-Check:"
# Paths subject to this check, in gitignore syntax (later patterns win).
_CHECKED_GLOBS = [
"/third_party/**",
# Rust crates are managed with Cargo (`fx update-rustc-third-party`), and
# the forks/compat shims under this directory mostly don't track an
# upstream revision at all.
"!/third_party/rust_crates/**",
]
def _nearest_readme_dir(path, readme_dirs):
"""Returns the closest ancestor of `path` that contains a README.fuchsia.
README.fuchsia files live at varying depths under third_party/ (e.g.
third_party/curl, third_party/pylibs/mypy, third_party/github.com/google/cppdap),
so the dependency a file belongs to is the nearest enclosing directory with a
README.fuchsia. Only directories strictly below third_party/ are considered.
Args:
path: Repo-relative path of a file under third_party/.
readme_dirs: Set of repo-relative directories containing a README.fuchsia.
Returns:
The directory path, or None if no enclosing README.fuchsia exists.
"""
parts = path.split("/")
for depth in range(len(parts) - 1, 1, -1):
candidate = "/".join(parts[:depth])
if candidate in readme_dirs:
return candidate
return None
def _third_party_readme_update(ctx):
"""Requires README.fuchsia to be updated when a dependency changes.
Any change to a file under third_party/ (an uprev, a local patch, a build
file edit, etc.) must also modify the dependency's README.fuchsia, so that
its Revision field, which the third-party freshness dashboard reads, is
kept current. Changes to the README.fuchsia alone are always allowed. The
requirement is waived by adding a `Skip-Readme-Check: <reason>` footer to
the commit message.
shac does not expose a file's previous contents, so whether the Revision
value itself changed cannot be verified; the README's fields are validated
separately by the readme_fuchsia_required_fields check.
Only files tracked directly in fuchsia.git are visible to this check. Most
dependencies live in their own repository (the third_party/<dep>/src
submodules pinned by the jiri manifest), and rolling one changes neither
a file under third_party/ nor anything ctx.scm.affected_files() reports.
Files with no enclosing README.fuchsia (e.g. under third_party/bazel_vendor)
are not checked; requiring a README.fuchsia to exist is a separate policy.
Neither is anything under third_party/rust_crates, see _CHECKED_GLOBS.
Args:
ctx: A ctx instance.
"""
affected_files = ctx.scm.affected_files(glob = _CHECKED_GLOBS, include_deleted = True)
# Under `shac check --all` (how presubmit runs) every file in the repository
# is returned, and only the files git reports as modified have a non-empty
# `action`. Only those are changes. When shac is given an explicit file
# list instead (e.g. `fx lint --files=...`), `action` is never set and the
# check does not run, since nothing distinguishes changed files from the
# rest; see the note on `action` in cpp.star.
modified_files = [path for path, meta in affected_files.items() if meta.action]
if not modified_files:
return
commits = ctx.scm.commits()
for commit in commits:
for line in commit.message.splitlines():
if not line.startswith(_SKIP_FOOTER):
continue
if line[len(_SKIP_FOOTER):].strip():
# The check has been explicitly waived.
return
ctx.emit.commit_message_finding(
level = "error",
message = "The `%s` footer must be followed by a reason." % _SKIP_FOOTER,
commit = commit,
)
readme_dirs = set()
for path in ctx.scm.all_files(glob = _README_NAME):
readme_dirs.add(path[:-len("/" + _README_NAME)])
# READMEs of the dependencies that have modified files. A README never
# counts as a modified file of its own dependency: a change to the README
# alone is always allowed, but once any other file of the dependency
# changes, the README must change too.
required = set()
for path in modified_files:
readme_dir = _nearest_readme_dir(path, readme_dirs)
if readme_dir:
readme = readme_dir + "/" + _README_NAME
if path != readme:
required.add(readme)
for readme in sorted(required):
if readme in modified_files:
continue
readme_dir = readme[:-len("/" + _README_NAME)]
message = (
"Files under %s were modified, but %s was not.\n\n" % (readme_dir, readme) +
"If this change merges a new upstream version, update the Revision " +
"field in %s.\n\n" % readme +
"Otherwise, add the following footer to the commit message to " +
"bypass this check:\n\n%s <reason>" % _SKIP_FOOTER
)
if commits:
# Attach the finding to the commit message, where the footer would
# be added, since the README may not be in the change at all.
ctx.emit.commit_message_finding(
level = "error",
message = message,
commit = commits[0],
)
else:
# No commits to attach to (e.g. uncommitted local changes).
ctx.emit.finding(level = "error", message = message)
def register_third_party_readme_checks():
shac.register_check(_third_party_readme_update)