| # 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) |