-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
scripts: ci: Detect API-breaking changes in the PRs
This script will check the PR's changes in the header files and DTS bindings that may affects compatibility of the public API. This will reduce risk of accidentally breaking the API. Workflow that runs on each PR will add a comment with analysis summary. Signed-off-by: Dominik Kilian <Dominik.Kilian@nordicsemi.no>
- Loading branch information
1 parent
cd66e53
commit 2be339c
Showing
22 changed files
with
2,278 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
name: API Check | ||
|
||
on: | ||
pull_request: | ||
branches: | ||
- main | ||
workflow_dispatch: | ||
inputs: | ||
new_commit: | ||
type: string | ||
required: true | ||
description: New Commit | ||
old_commit: | ||
type: string | ||
required: true | ||
description: Old Commit | ||
|
||
jobs: | ||
build: | ||
runs-on: ubuntu-latest | ||
concurrency: | ||
group: ${{ github.workflow }}-${{ github.ref }} | ||
cancel-in-progress: true | ||
steps: | ||
- name: Checkout sources | ||
uses: nordicbuilder/action-checkout-west-update@main | ||
with: | ||
git-fetch-depth: 0 | ||
west-update-args: '' | ||
|
||
- name: cache-pip | ||
uses: actions/cache@v3 | ||
with: | ||
path: ~/.cache/pip | ||
key: ${{ runner.os }}-doc-pip | ||
|
||
- name: Git rebase | ||
if: github.event_name == 'pull_request' | ||
env: | ||
BASE_REF: ${{ github.base_ref }} | ||
working-directory: ncs/nrf | ||
run: | | ||
git remote -v | ||
git branch | ||
git rebase origin/${BASE_REF} | ||
# debug | ||
git log --pretty=oneline -n 5 | ||
- name: Install packages | ||
run: | | ||
sudo apt update | ||
sudo apt-get install -y ninja-build mscgen plantuml | ||
sudo snap install yq | ||
DOXYGEN_VERSION=$(yq ".doxygen.version" ./ncs/nrf/scripts/tools-versions-linux.yml) | ||
wget --no-verbose "https://github.com/doxygen/doxygen/releases/download/Release_${DOXYGEN_VERSION//./_}/doxygen-${DOXYGEN_VERSION}.linux.bin.tar.gz" | ||
tar xf doxygen-${DOXYGEN_VERSION}.linux.bin.tar.gz | ||
echo "${PWD}/doxygen-${DOXYGEN_VERSION}/bin" >> $GITHUB_PATH | ||
cp -r ncs/nrf/scripts/ci/api_check . | ||
- name: Install Python dependencies | ||
working-directory: ncs | ||
run: | | ||
sudo pip3 install -U setuptools wheel pip | ||
pip3 install -r nrf/doc/requirements.txt | ||
pip3 install -r ../api_check/requirements.txt | ||
- name: West zephyr-export | ||
working-directory: ncs | ||
run: | | ||
west zephyr-export | ||
- name: Checkout new commit and west update | ||
if: github.event_name == 'workflow_dispatch' | ||
working-directory: ncs/nrf | ||
run: | | ||
git checkout ${{ github.event.inputs.new_commit }} | ||
west update | ||
- name: Collect data from new commit | ||
working-directory: ncs/nrf | ||
run: | | ||
source ../zephyr/zephyr-env.sh | ||
echo =========== NEW COMMIT =========== | ||
git log -n 1 | ||
cmake -GNinja -Bdoc/_build -Sdoc | ||
python3 ../../api_check/utils/interrupt_on.py "syncing doxygen output" ninja -C doc/_build nrf | ||
python3 ../../api_check/headers doc/_build/nrf/doxygen/xml --save-input ../../headers-new.pkl | ||
python3 ../../api_check/dts -n - --save-input ../../dts-new.pkl | ||
rm -Rf doc/_build | ||
- name: Checkout old commit and west update | ||
working-directory: ncs/nrf | ||
run: | | ||
git checkout ${{ github.event.inputs.old_commit }}${{ github.base_ref }} | ||
cd .. | ||
west update | ||
- name: Collect data from old commit | ||
working-directory: ncs/nrf | ||
run: | | ||
source ../zephyr/zephyr-env.sh | ||
echo =========== OLD COMMIT =========== | ||
git log -n 1 | ||
cmake -GNinja -Bdoc/_build -Sdoc | ||
python3 ../../api_check/utils/interrupt_on.py "syncing doxygen output" ninja -C doc/_build nrf | ||
python3 ../../api_check/headers doc/_build/nrf/doxygen/xml --save-input ../../headers-old.pkl | ||
python3 ../../api_check/dts -n - --save-input ../../dts-old.pkl | ||
- name: Check | ||
working-directory: ncs/nrf | ||
run: | | ||
python3 ../../api_check/headers --format github --resolve-paths . --relative-to . --save-stats ../../headers-stats.json ../../headers-new.pkl ../../headers-old.pkl || true | ||
python3 ../../api_check/dts --format github --relative-to . --save-stats ../../dts-stats.json -n ../../dts-new.pkl -o ../../dts-old.pkl || true | ||
echo Headers stats | ||
cat ../../headers-stats.json || true | ||
echo DTS stats | ||
cat ../../dts-stats.json || true | ||
- name: Update PR | ||
if: github.event_name == 'pull_request' | ||
working-directory: ncs/nrf | ||
env: | ||
PR_NUMBER: ${{ github.event.number }} | ||
GITHUB_ACTOR: ${{ github.actor }} | ||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
GITHUB_REPO: ${{ github.repository }} | ||
GITHUB_RUN_ID: ${{ github.run_id }} | ||
run: | | ||
python3 ../../api_check/pr ../../headers-stats.json ../../dts-stats.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
from main import main | ||
main() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
# Copyright (c) 2024 Nordic Semiconductor ASA | ||
# | ||
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause | ||
|
||
import sys | ||
import argparse | ||
from pathlib import Path | ||
|
||
|
||
class ArgsClass: | ||
new: 'list[list[str]]' | ||
old: 'list[list[str]]|None' | ||
format: str | ||
relative_to: 'Path | None' | ||
save_stats: 'Path | None' | ||
save_input: 'Path | None' | ||
save_old_input: 'Path | None' | ||
dump_json: 'Path | None' | ||
|
||
|
||
def parse_args() -> ArgsClass: | ||
parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False, | ||
description='Detect DTS binding changes.') | ||
parser.add_argument('-n', '--new', nargs='+', action='append', required=True, | ||
help='List of directories where to search the new DTS binding. ' + | ||
'The "-" will use the "ZEPHYR_BASE" environment variable to find ' + | ||
'DTS binding in default directories.') | ||
parser.add_argument('-o', '--old', nargs='+', action='append', | ||
help='List of directories where to search the old DTS binding. ' + | ||
'The "-" will use the "ZEPHYR_BASE" environment variable to find ' + | ||
'DTS binding in default directories. You should skip this if you ' + | ||
'want to pre-parse the input with the "--save-input" option.') | ||
parser.add_argument('--format', choices=('text', 'github'), default='text', | ||
help='Output format. Default is "text".') | ||
parser.add_argument('--relative-to', type=Path, | ||
help='Show relative paths in messages.') | ||
parser.add_argument('--save-stats', type=Path, | ||
help='Save statistics to JSON file.') | ||
parser.add_argument('--save-input', metavar='FILE', type=Path, | ||
help='Pre-parse and save the new input to a file. The file format may change ' + | ||
'from version to version. Use always the same version ' + | ||
'of this tool for one file.') | ||
parser.add_argument('--save-old-input', metavar='FILE', type=Path, | ||
help='Pre-parse and save the old input to a file.') | ||
parser.add_argument('--dump-json', metavar='FILE', type=Path, | ||
help='Dump input data to a JSON file (only for debug purposes).') | ||
parser.add_argument('--help', action='help', | ||
help='Show this help and exit.') | ||
args: ArgsClass = parser.parse_args() | ||
|
||
if (args.old is None) and (args.save_input is None): | ||
parser.print_usage() | ||
print('error: at least one of the following arguments is required: old-input, --save-input', file=sys.stderr) | ||
sys.exit(2) | ||
|
||
args.relative_to = args.relative_to.absolute() if args.relative_to else None | ||
|
||
return args | ||
|
||
|
||
args: ArgsClass = parse_args() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
# Copyright (c) 2024 Nordic Semiconductor ASA | ||
# | ||
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause | ||
|
||
import sys | ||
import pickle | ||
from pathlib import Path | ||
from dts_tools import devicetree_sources, warning | ||
|
||
if devicetree_sources: | ||
sys.path.insert(0, devicetree_sources) | ||
|
||
from devicetree import edtlib | ||
|
||
|
||
class ParseResult: | ||
bindings: 'list[Binding]' | ||
binding_by_name: 'dict[str, Binding]' | ||
def __init__(self): | ||
self.bindings = [] | ||
self.binding_by_name = {} | ||
|
||
class Property: | ||
name: str | ||
type: str | ||
description: str | ||
enum: 'set[str]' | ||
const: 'str | None' | ||
default: 'str | None' | ||
deprecated: bool | ||
required: bool | ||
specifier_space: str | ||
|
||
def __init__(self, prop: edtlib.PropertySpec): | ||
self.name = prop.name | ||
self.type = prop.type or '' | ||
self.description = prop.description or '' | ||
self.enum = { str(x) for x in (prop.enum or []) } | ||
self.const = str(prop.const) if prop.const else None | ||
self.default = str(prop.default) if prop.default else None | ||
self.deprecated = prop.deprecated or False | ||
self.required = prop.required or False | ||
self.specifier_space = str(prop.specifier_space or '') | ||
|
||
class Binding: | ||
path: str | ||
name: str | ||
description: str | ||
cells: str | ||
buses: str | ||
properties: 'dict[str, Property]' | ||
|
||
def __init__(self, binding: edtlib.Binding, file: Path): | ||
self.path = str(file) | ||
self.name = binding.compatible or self.path | ||
if binding.on_bus is not None: | ||
self.name += '@' + binding.on_bus | ||
self.description = binding.description or '' | ||
cells_array = [ | ||
f'{name}={";".join(value)}' for name, value in (binding.specifier2cells or {}).items() | ||
] | ||
cells_array.sort() | ||
self.cells = '&'.join(cells_array) | ||
busses_array = list(binding.buses or []) | ||
busses_array.sort() | ||
self.buses = ';'.join(busses_array) | ||
self.properties = {} | ||
for key, value in (binding.prop2specs or {}).items(): | ||
prop = Property(value) | ||
self.properties[key] = prop | ||
|
||
|
||
def get_binding_files(bindings_dirs: 'list[Path]') -> 'list[Path]': | ||
binding_files = [] | ||
for bindings_dir in bindings_dirs: | ||
if not bindings_dir.is_dir(): | ||
raise FileNotFoundError(f'Bindings directory "{bindings_dir}" not found.') | ||
for file in bindings_dir.glob('**/*.yaml'): | ||
binding_files.append(file) | ||
for file in bindings_dir.glob('**/*.yml'): | ||
binding_files.append(file) | ||
return binding_files | ||
|
||
|
||
def parse_bindings(dirs_or_pickle: 'list[Path]|Path') -> ParseResult: | ||
result = ParseResult() | ||
if isinstance(dirs_or_pickle, list): | ||
yaml_files = get_binding_files(dirs_or_pickle) | ||
fname2path: 'dict[str, str]' = { | ||
path.name: str(path) for path in yaml_files | ||
} | ||
for binding_file in yaml_files: | ||
try: | ||
binding = Binding(edtlib.Binding(str(binding_file), fname2path, None, False, False), binding_file) | ||
if binding.name in result.binding_by_name: | ||
warning(f'Repeating binding {binding.name}: {binding.path} {result.binding_by_name[binding.name].path}') | ||
result.bindings.append(binding) | ||
result.binding_by_name[binding.name] = binding | ||
except edtlib.EDTError as err: | ||
warning(err) | ||
else: | ||
with open(dirs_or_pickle, 'rb') as fd: | ||
result = pickle.load(fd) | ||
return result | ||
|
||
|
||
def save_bindings(parse_result: ParseResult, file: Path): | ||
with open(file, 'wb') as fd: | ||
pickle.dump(parse_result, fd) |
Oops, something went wrong.