This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 340b6fa0d chore(dev): fold the crate LICENSE/NOTICE check into
licenses.py (#3528)
340b6fa0d is described below
commit 340b6fa0d1e44fbcfdac68174298519e35819e47
Author: NingLin Liu <[email protected]>
AuthorDate: Tue Oct 27 05:09:03 2026 +0000
chore(dev): fold the crate LICENSE/NOTICE check into licenses.py (#3528)
* chore(dev): fold the crate LICENSE/NOTICE check into licenses.py
* chore(dev): address review on crates check
- drop --no-verify (implied by --list) and inline the --allow-dirty comment
- check cargo the same way as cargo-deny and keep the two helpers together
- don't capture cargo metadata stderr so warnings stay visible
- compare against the root file without raising when either side is missing
- reword the crates sub-command help
---------
Co-authored-by: NingLin Liu <[email protected]>
---
Makefile | 2 +-
dev/check_license_notice.sh | 188 --------------------------------------------
dev/release/licenses.py | 144 +++++++++++++++++++++++++++++++++
3 files changed, 145 insertions(+), 189 deletions(-)
diff --git a/Makefile b/Makefile
index 9da47d186..485d2866b 100644
--- a/Makefile
+++ b/Makefile
@@ -90,7 +90,7 @@ check-public-api: install-cargo-public-api
if [ $$fail -ne 0 ]; then exit 1; fi
check-license-notice:
- bash ./dev/check_license_notice.sh
+ python3 ./dev/release/licenses.py crates check
# Keep version in sync with the CI lint install step in
.github/workflows/ci.yml.
install-cargo-deny:
diff --git a/dev/check_license_notice.sh b/dev/check_license_notice.sh
deleted file mode 100755
index 17757fb9a..000000000
--- a/dev/check_license_notice.sh
+++ /dev/null
@@ -1,188 +0,0 @@
-#!/usr/bin/env bash
-#
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-# Every distributed artifact must ship its own LICENSE and NOTICE.
-# Checking the packaged file list, rather than the crate directory,
-# also catches files that exist on disk but are excluded from the published
-# crate. Each crate's copy must be the repository root file itself, so the
-# license text cannot drift from one crate to the next.
-
-set -Eeuo pipefail
-
-REQUIRED_FILES=(LICENSE NOTICE)
-
-usage() {
- cat <<USAGE
-Usage:
- $0 [-h|--help]
-
-Checks that every publishable crate in the workspace packages a top-level
-LICENSE and NOTICE file, as required for Apache source and binary releases.
-USAGE
-}
-
-require_command() {
- local command_name="$1"
- if ! command -v "${command_name}" >/dev/null 2>&1; then
- echo "This script requires '${command_name}', but it is not installed." >&2
- return 1
- fi
-}
-
-report_error() {
- # GitHub Actions turns `::error::` lines into annotations on the job summary.
- if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
- echo "::error::$1"
- else
- echo "ERROR: $1" >&2
- fi
-}
-
-# Return name and manifest path for every crate that would be published in a
release.
-publishable_crates() {
- # `publish` is an empty list for `publish = false` crates and null when the
- # crate may be published to any registry.
- cargo metadata --no-deps --format-version 1 |
- jq -r '.packages[] | select(.publish != []) |
"\(.name)\t\(.manifest_path)"'
-}
-
-# Check the path resolves to a non-empty regular file, following any symlinks.
-check_file_exists() {
- local path="$1"
-
- # `-f` follows symlinks and rejects directories.
- # `-s` additionally requires content, so an emptied file fails too.
- if [ ! -f "${path}" ] || [ ! -s "${path}" ]; then
- return 1
- fi
-}
-
-# Return true if both paths are the same file.
-is_same_file() {
- # `-ef` is short for "equal file": it is true when both arguments resolve to
the
- # same device and inode.
- [ "$1" -ef "$2" ]
-}
-
-# Return true if the given path (second arg) is present in the list of paths
(first arg).
-packages_file_at_root() {
- local packaged_paths="$1"
- local file_name="$2"
-
- local packaged_path
-
- while IFS= read -r packaged_path; do
- if [ "${packaged_path}" = "${file_name}" ]; then
- return 0
- fi
- done <<<"${packaged_paths}"
-
- return 1
-}
-
-# Verify that the necessary files are bundled in the crate.
-# Accepts both crate name and its directory, so that artifacts can be fully
verified including symlink resolution.
-check_crate() {
- local crate="$1"
- local crate_dir="$2"
-
- local packaged_paths status repo_path
- status=0
-
- echo "Checking ${crate}..."
- # --no-verify is no-op at the time of writing, but to be clear: packages
will not be built, only expected files enumerated
- # --allow-dirty allows for `make check` during development
- if ! packaged_paths="$(cargo package --package "${crate}" --list --locked
--no-verify --allow-dirty)"; then
- report_error "${crate} could not be listed by cargo; see the error above"
- return 1
- fi
-
- for required_file in "${REQUIRED_FILES[@]}"; do
- # Not only verify that the path is included, but verify that it links to
the root file.
- repo_path="${crate_dir}/${required_file}"
- if ! packages_file_at_root "${packaged_paths}" "${required_file}"; then
- report_error "${crate} crate is missing ${required_file} in packaged
file list"
- status=1
- elif ! check_file_exists "${repo_path}"; then
- report_error "${crate} ${required_file} does not resolve to a non-empty
file: ${repo_path}"
- status=1
- elif ! is_same_file "${repo_path}" "${REPO_ROOT}/${required_file}"; then
- report_error "${crate} ${required_file} does not resolve to the
repository root ${required_file}: ${repo_path}"
- status=1
- fi
- done
-
- return "${status}"
-}
-
-# Route CLI args
-while [ "$#" -gt 0 ]; do
- case "$1" in
- -h | --help)
- usage
- exit 0
- ;;
- *)
- echo "Unknown argument: $1" >&2
- usage >&2
- exit 1
- ;;
- esac
-done
-
-require_command cargo
-require_command jq
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
-cd "${REPO_ROOT}"
-
-FAILED=0
-
-echo "Checking repository root..."
-for required_file in "${REQUIRED_FILES[@]}"; do
- if ! check_file_exists "${REPO_ROOT}/${required_file}"; then
- report_error "Repository root ${required_file} does not resolve to a
non-empty file: ${REPO_ROOT}/${required_file}"
- FAILED=1
- fi
-done
-
-# Check for non-zero exit code, otherwise store in `PUBLISHABLE_CRATES`
-# Must be checked here rather than piped directly, otherwise the error code is
ignored
-if ! PUBLISHABLE_CRATES="$(publishable_crates)"; then
- report_error "Could not list the publishable crates"
- exit 1
-fi
-
-# Verify at least one crate was listed
-if [ -z "${PUBLISHABLE_CRATES}" ]; then
- report_error "No publishable crates found in the workspace, expected at
least one"
- exit 1
-fi
-
-while IFS=$'\t' read -r crate manifest_path; do
- check_crate "${crate}" "$(dirname "${manifest_path}")" || FAILED=1
-done <<<"${PUBLISHABLE_CRATES}"
-
-if [ "${FAILED}" -ne 0 ]; then
- echo "Every publishable crate must package a top-level LICENSE and NOTICE
resolving to the repository root copies." >&2
- exit 1
-fi
-
-echo "All publishable crates package LICENSE and NOTICE."
diff --git a/dev/release/licenses.py b/dev/release/licenses.py
index 2fc744987..a0bc83c19 100755
--- a/dev/release/licenses.py
+++ b/dev/release/licenses.py
@@ -20,12 +20,17 @@ CLI for assisting maintenance of the project's license
checks.
"""
import argparse
+import json
+import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
+# Every distributed artifact must ship its own copy of these files.
+REQUIRED_CRATE_FILES = ("LICENSE", "NOTICE")
+
class Failure(Exception):
"""A user-facing error."""
@@ -39,6 +44,26 @@ def ok(message: str) -> None:
print(f"OK: {message}", flush=True)
+def report_error(message: str) -> None:
+ # GitHub Actions turns `::error::` lines into annotations on the job
summary.
+ if os.environ.get("GITHUB_ACTIONS") == "true":
+ print(f"::error::{message}", flush=True)
+ else:
+ print(f"ERROR: {message}", file=sys.stderr, flush=True)
+
+
+def require_cargo() -> None:
+ """Assert cargo is installed."""
+ try:
+ subprocess.run(
+ ["cargo", "--version"],
+ capture_output=True,
+ check=True,
+ )
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ raise Failure("This script requires 'cargo', but it is not installed.")
+
+
def require_cargo_deny() -> None:
"""Assert cargo-deny is installed."""
try:
@@ -56,6 +81,113 @@ def require_cargo_deny() -> None:
)
+def is_non_empty_file(path: Path) -> bool:
+ """Whether the path resolves, following symlinks, to a non-empty regular
file."""
+ return path.is_file() and path.stat().st_size > 0
+
+
+def is_same_file(path: Path, other: Path) -> bool:
+ """Whether both paths exist and resolve to the same file; False if either
is missing."""
+ try:
+ return path.samefile(other)
+ except OSError:
+ return False
+
+
+def publishable_crates() -> list[tuple[str, Path]]:
+ """Name and directory of every crate that would be published in a
release."""
+ result = subprocess.run(
+ ["cargo", "metadata", "--no-deps", "--format-version", "1"],
+ cwd=REPO_ROOT,
+ stdout=subprocess.PIPE,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ raise Failure("Could not list the publishable crates.")
+
+ # `publish` is an empty list for `publish = false` crates and null when the
+ # crate may be published to any registry.
+ return [
+ (package["name"], Path(package["manifest_path"]).parent)
+ for package in json.loads(result.stdout)["packages"]
+ if package["publish"] != []
+ ]
+
+
+def check_crate_files(crate: str, crate_dir: Path) -> bool:
+ """Check the packaged crate ships LICENSE and NOTICE resolving to the root
files."""
+ print(f"Checking {crate}...", flush=True)
+ result = subprocess.run(
+ [
+ "cargo",
+ "package",
+ "--package",
+ crate,
+ "--list",
+ "--locked",
+ # allows `make check` during development.
+ "--allow-dirty",
+ ],
+ cwd=REPO_ROOT,
+ stdout=subprocess.PIPE,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ report_error(f"{crate} could not be listed by cargo; see the error
above")
+ return False
+
+ packaged_paths = set(result.stdout.splitlines())
+ passed = True
+ for file_name in REQUIRED_CRATE_FILES:
+ repo_path = crate_dir / file_name
+ if file_name not in packaged_paths:
+ report_error(f"{crate} crate is missing {file_name} in packaged
file list")
+ passed = False
+ elif not is_non_empty_file(repo_path):
+ report_error(f"{crate} {file_name} does not resolve to a non-empty
file: {repo_path}")
+ passed = False
+ elif not is_same_file(repo_path, REPO_ROOT / file_name):
+ report_error(
+ f"{crate} {file_name} does not resolve to the repository root
{file_name}: {repo_path}"
+ )
+ passed = False
+ return passed
+
+
+def command_check_crates(_args: argparse.Namespace) -> None:
+ # Checking the packaged file list, rather than the crate directory, also
+ # catches files that exist on disk but are excluded from the published
crate.
+ # Each crate's copy must be the repository root file itself, so the license
+ # text cannot drift from one crate to the next.
+ step("Check crate LICENSE and NOTICE files")
+ require_cargo()
+
+ passed = True
+ for file_name in REQUIRED_CRATE_FILES:
+ if not is_non_empty_file(REPO_ROOT / file_name):
+ report_error(
+ f"Repository root {file_name} does not resolve to a non-empty
file: {REPO_ROOT / file_name}"
+ )
+ passed = False
+
+ crates = publishable_crates()
+ if not crates:
+ raise Failure("No publishable crates found in the workspace, expected
at least one.")
+
+ for crate, crate_dir in crates:
+ passed = check_crate_files(crate, crate_dir) and passed
+
+ if not passed:
+ raise Failure(
+ "Every publishable crate must package a top-level LICENSE and
NOTICE "
+ "resolving to the repository root copies."
+ )
+
+ ok("Check crate LICENSE and NOTICE files")
+
+
def command_check_dependencies(_args: argparse.Namespace) -> None:
step("Check dependency licenses")
require_cargo_deny()
@@ -87,6 +219,18 @@ def main() -> int:
)
check.set_defaults(func=command_check_dependencies)
+ crates = cmd_groups.add_parser(
+ "crates",
+ help="Sub-commands for working with licensing on crates",
+ )
+ crates_commands = crates.add_subparsers(dest="command", required=True)
+
+ check = crates_commands.add_parser(
+ "check",
+ help="assert every publishable crate packages the repository root
LICENSE and NOTICE",
+ )
+ check.set_defaults(func=command_check_crates)
+
args = parser.parse_args()
try:
args.func(args)