diff --git a/.github/workflows/on_sync_pr.yml b/.github/workflows/on_sync_pr.yml new file mode 100644 index 00000000..1b344520 --- /dev/null +++ b/.github/workflows/on_sync_pr.yml @@ -0,0 +1,96 @@ +name: SDK sync PR build + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: + - sdk-nrf + +permissions: + contents: read + +concurrency: + group: ncs-matter-sdk-sync-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_COMPILERCHECK: content + CCACHE_MAXSIZE: 2G + CCACHE_SLOPPINESS: file_macro,time_macros,include_file_mtime,include_file_ctime + WEST_PATH_CACHE: ${{ github.workspace }}/west-path-cache + +jobs: + template-twister: + if: | + github.event.pull_request.head.ref == 'sdk-sync/test' && + github.event.pull_request.base.ref == 'sdk-nrf' + runs-on: ubuntu-24.04 + container: ghcr.io/nrfconnect/sdk-nrf-toolchain:v3.4.0 + defaults: + run: + shell: bash + steps: + - name: Checkout pull request + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + path: ncs-matter + + - name: Restore west module path cache + uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4 + with: + path: west-path-cache + key: west-path-cache-${{ hashFiles('ncs-matter/west.yml') }} + restore-keys: | + west-path-cache- + + - name: Restore ccache + uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4 + with: + path: .ccache + key: ccache-ncs-matter-${{ hashFiles('ncs-matter/west.yml') }}-${{ github.base_ref }} + restore-keys: | + ccache-ncs-matter-${{ hashFiles('ncs-matter/west.yml') }}- + ccache-ncs-matter- + + - name: Restore pip cache + uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4 + with: + path: ~/.cache/pip + key: pip-ncs-matter-${{ hashFiles('ncs-matter/west.yml') }} + + - name: Prepare west workspace + run: | + rm -rf .west + west --version + west init -l ncs-matter --mf west.yml + west update -n -o=--depth=1 --path-cache "${WEST_PATH_CACHE}" + + - name: Install Python deps for Matter + run: | + pip3 install -r nrf/scripts/requirements-extra.txt + pip3 install -r modules/lib/matter/scripts/setup/requirements.nrfconnect.txt + + - name: Set up ccache + run: | + mkdir -p "${CCACHE_DIR}" + ccache -z + ccache -s + + - name: Twister build Matter template sample + run: | + source zephyr/zephyr-env.sh + zephyr/scripts/twister \ + --inline-logs \ + --build-only \ + -N \ + --verbose \ + -T ncs-matter/samples/template \ + --scenario sample.matter.template.debug \ + --all + + - name: Print ccache statistics + if: always() + run: ccache -s diff --git a/.github/workflows/sdk-sync.yml b/.github/workflows/sdk-sync.yml new file mode 100644 index 00000000..45ad25f9 --- /dev/null +++ b/.github/workflows/sdk-sync.yml @@ -0,0 +1,76 @@ +name: SDK Sync + +on: + schedule: + - cron: '0 0 * * 0' + workflow_dispatch: + inputs: + nrf_repo: + description: 'sdk-nrf repository (owner/name)' + required: false + default: 'ArekBalysNordic/sdk-nrf' + chip_repo: + description: 'sdk-connectedhomeip repository (owner/name)' + required: false + default: 'ArekBalysNordic/sdk-connectedhomeip' + +permissions: + contents: write + pull-requests: write + +jobs: + sdk-sync: + runs-on: ubuntu-24.04 + env: + SDK_SYNC_PAT: ${{ secrets.SDK_SYNC_PAT }} + GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} + SDK_SYNC_NRF_REPO: ${{ inputs.nrf_repo || 'ArekBalysNordic/sdk-nrf' }} + SDK_SYNC_CHIP_REPO: ${{ inputs.chip_repo || 'ArekBalysNordic/sdk-connectedhomeip' }} + steps: + - name: Checkout the code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set state file path + run: echo "STATE_FILE=${RUNNER_TEMP}/sdk_sync_state.json" >> "$GITHUB_ENV" + + - name: Set up Python + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: scripts/sdk_sync/requirements.txt + + - name: Install dependencies + run: pip install -r scripts/sdk_sync/requirements.txt + + - name: Configure git credentials + run: | + git remote set-url origin "https://x-access-token:${SDK_SYNC_PAT}@github.com/${{ github.repository }}.git" + git config --global user.email "sdk-sync-bot@users.noreply.github.com" + git config --global user.name "sdk-sync-bot" + + - name: Synchronize sdk-nrf with main + run: python scripts/sdk_sync/sync_integration_branch.py --main-branch main --integration-branch sdk-nrf + + - name: Synchronize sdk-connectedhomeip sdk-nrf with master + run: python scripts/sdk_sync/sync_chip_branch.py + + - name: Collect upstream revision report + run: python scripts/sdk_sync/chip_sync_report.py --state-file "${STATE_FILE}" + + - name: Prepare test branch from sdk-nrf + run: python scripts/sdk_sync/prepare_pr.py --command prepare-branch --source-branch sdk-nrf --state-file "${STATE_FILE}" + + - name: Update west.yml on test branch + run: | + git checkout sdk-sync/test + python scripts/sdk_sync/update_manifest.py --state-file "${STATE_FILE}" --branch sdk-sync/test + + - name: Open sync pull request + run: python scripts/sdk_sync/prepare_pr.py --command open-pr --base-branch sdk-nrf --state-file "${STATE_FILE}" + + - name: Post sync summary comment + run: python scripts/sdk_sync/build_report.py --state-file "${STATE_FILE}" diff --git a/scripts/sdk_sync/__init__.py b/scripts/sdk_sync/__init__.py new file mode 100644 index 00000000..ff213617 --- /dev/null +++ b/scripts/sdk_sync/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2026 Nordic Semiconductor ASA +# SPDX-License-Identifier: Apache-2.0 + +"""Scripts for weekly sdk-nrf / sdk-connectedhomeip synchronization.""" diff --git a/scripts/sdk_sync/build_report.py b/scripts/sdk_sync/build_report.py new file mode 100644 index 00000000..ae84587b --- /dev/null +++ b/scripts/sdk_sync/build_report.py @@ -0,0 +1,221 @@ +# Copyright (c) 2026 Nordic Semiconductor ASA +# SPDX-License-Identifier: Apache-2.0 + +"""Build and post the sdk-sync summary comment on the test PR.""" + +from __future__ import annotations + +import argparse +import os +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from common import ( # noqa: E402 + FROM_NRF_MARKER, + REPORT_MARKER, + ChipSyncReport, + SyncState, + chip_repo, + github_get, + github_get_paginated, + github_headers, + github_token, + nrf_repo, + read_json, + short_sha, + within_last_days, +) + +import requests + + +def recent_commits(*, repo: str, branch: str, days: int) -> list[dict]: + since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ") + commits = github_get_paginated( + f"/repos/{repo}/commits", + params={"sha": branch, "since": since, "per_page": 100}, + ) + recent: list[dict] = [] + for commit in commits: + subject = commit["commit"]["message"].splitlines()[0] + recent.append( + { + "sha": commit["sha"], + "subject": subject, + "html_url": commit["html_url"], + } + ) + return recent + + +def recent_pull_requests(*, repo: str, days: int) -> list[dict]: + pulls = github_get_paginated( + f"/repos/{repo}/pulls", + params={"state": "all", "sort": "updated", "direction": "desc", "per_page": 100}, + ) + recent = [] + for pull in pulls: + if within_last_days(pull.get("updated_at"), days) or within_last_days(pull.get("merged_at"), days): + recent.append(pull) + return recent + + +def render_pr_list(pulls: list[dict]) -> str: + if not pulls: + return "_No pull requests updated in the last 7 days._\n" + lines = [] + for pull in pulls: + number = pull["number"] + title = pull["title"].replace("\n", " ") + url = pull["html_url"] + state = pull.get("state", "unknown") + lines.append(f"- [#{number} {title}]({url}) ({state})") + return "\n".join(lines) + "\n" + + +def render_commit_list(commits: list[dict], *, empty_message: str = "_None._") -> str: + if not commits: + return f"{empty_message}\n" + lines = [] + for commit in commits: + lines.append(f"- [{commit['subject']}]({commit['html_url']}) (`{short_sha(commit['sha'])}`)") + return "\n".join(lines) + "\n" + + +def build_report_body( + *, + state: SyncState, + days: int, + nrf_repo_name: str, + chip_repo_name: str, + nrf_branch: str = "main", + chip_branch: str = "sdk-nrf", +) -> str: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + chip = state.chip_sync + nrf_commits = recent_commits(repo=nrf_repo_name, branch=nrf_branch, days=days) + chip_commits = recent_commits(repo=chip_repo_name, branch=chip_branch, days=days) + nrf_prs = recent_pull_requests(repo=nrf_repo_name, days=days) + chip_prs = recent_pull_requests(repo=chip_repo_name, days=days) + + body = f"""{REPORT_MARKER} +## Weekly SDK sync report + +Generated: {now} + +### Manifest revisions under test + +| Project | Revision | +|---------|----------| +| `sdk-nrf` (`nrf`, `{nrf_branch}`) | `{state.nrf_sha}` | +| `sdk-connectedhomeip` (`matter`, `{chip_branch}`) | `{state.matter_sha}` | + +
+sdk-nrf commits on `{nrf_branch}` (last {days} days, {len(nrf_commits)}) + +{render_commit_list(nrf_commits, empty_message=f"_No commits on `{nrf_branch}` in the last {days} days._")} +
+ +
+sdk-connectedhomeip commits on `{chip_branch}` (last {days} days, {len(chip_commits)}) + +{render_commit_list(chip_commits, empty_message=f"_No commits on `{chip_branch}` in the last {days} days._")} +
+ +
+sdk-nrf pull requests (last {days} days, {len(nrf_prs)}) + +{render_pr_list(nrf_prs)} +
+ +
+sdk-connectedhomeip pull requests (last {days} days, {len(chip_prs)}) + +{render_pr_list(chip_prs)} +
+ +
+sdk-connectedhomeip commits above merge-base ({len(chip.commits_above_anchor)}) + +{render_commit_list([c.__dict__ for c in chip.commits_above_anchor])} +
+ +
+Commits missing {FROM_NRF_MARKER} ({len(chip.commits_missing_from_nrf)}) + +{render_commit_list([c.__dict__ for c in chip.commits_missing_from_nrf])} +
+""" + return body + + +def find_existing_comment(*, repo: str, pr_number: int) -> int | None: + comments = github_get_paginated(f"/repos/{repo}/issues/{pr_number}/comments") + for comment in comments: + if REPORT_MARKER in comment.get("body", ""): + return int(comment["id"]) + return None + + +def upsert_pr_comment(*, repo: str, pr_number: int, body: str) -> None: + existing_id = find_existing_comment(repo=repo, pr_number=pr_number) + headers = github_headers() + if existing_id is not None: + response = requests.patch( + f"https://api.github.com/repos/{repo}/issues/comments/{existing_id}", + headers=headers, + json={"body": body}, + timeout=60, + ) + action = "Updated" + else: + response = requests.post( + f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments", + headers=headers, + json={"body": body}, + timeout=60, + ) + action = "Posted" + if response.status_code >= 400: + sys.exit(f"Failed to comment on PR #{pr_number}: {response.status_code} {response.text}") + print(f"{action} report comment on PR #{pr_number}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "nrfconnect/ncs-matter")) + parser.add_argument("--nrf-repo", default=None) + parser.add_argument("--chip-repo", default=None) + parser.add_argument("--nrf-branch", default=os.environ.get("SDK_SYNC_NRF_BRANCH", "main")) + parser.add_argument("--chip-branch", default=os.environ.get("SDK_SYNC_CHIP_BRANCH", "sdk-nrf")) + parser.add_argument("--state-file", type=Path, required=True) + parser.add_argument("--days", type=int, default=7) + args = parser.parse_args() + + _ = github_token() + raw = read_json(args.state_file) + if "pr_number" not in raw: + print("No PR was opened; skipping report comment") + return + + nrf_repo_name = args.nrf_repo or nrf_repo() + chip_repo_name = args.chip_repo or chip_repo() + state = SyncState.from_dict(raw) + body = build_report_body( + state=state, + days=args.days, + nrf_repo_name=nrf_repo_name, + chip_repo_name=chip_repo_name, + nrf_branch=args.nrf_branch, + chip_branch=args.chip_branch, + ) + upsert_pr_comment(repo=args.repo, pr_number=state.pr_number, body=body) + + +if __name__ == "__main__": + main() diff --git a/scripts/sdk_sync/chip_sync_report.py b/scripts/sdk_sync/chip_sync_report.py new file mode 100644 index 00000000..6cc2952c --- /dev/null +++ b/scripts/sdk_sync/chip_sync_report.py @@ -0,0 +1,106 @@ +# Copyright (c) 2026 Nordic Semiconductor ASA +# SPDX-License-Identifier: Apache-2.0 + +"""Compare sdk-connectedhomeip master and sdk-nrf branches.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from common import ( # noqa: E402 + FROM_NRF_MARKER, + ChipSyncReport, + CommitInfo, + chip_repo, + github_get, + nrf_repo, + read_json, + write_json, +) + + +def branch_tip(*, repo: str, branch: str) -> str: + ref = github_get(f"/repos/{repo}/git/ref/heads/{branch}") + return ref["object"]["sha"] + + +def merge_base(*, repo: str, base: str, head: str) -> str: + comparison = github_get(f"/repos/{repo}/compare/{base}...{head}") + return comparison["merge_base_commit"]["sha"] + + +def commits_on_branch(*, repo: str, branch: str, since_sha: str) -> list[CommitInfo]: + comparison = github_get(f"/repos/{repo}/compare/{since_sha}...{branch}") + commits: list[CommitInfo] = [] + for commit in comparison.get("commits", []): + subject = commit["commit"]["message"].splitlines()[0] + commits.append( + CommitInfo( + sha=commit["sha"], + subject=subject, + html_url=commit["html_url"], + has_from_nrf=FROM_NRF_MARKER in subject, + ) + ) + return commits + + +def build_chip_sync_report(*, chip_repo_name: str | None = None) -> ChipSyncReport: + repo = chip_repo_name or chip_repo() + master_sha = branch_tip(repo=repo, branch="master") + sdk_nrf_sha = branch_tip(repo=repo, branch="sdk-nrf") + anchor_sha = merge_base(repo=repo, base="master", head="sdk-nrf") + above_anchor = commits_on_branch(repo=repo, branch="sdk-nrf", since_sha=anchor_sha) + missing = [commit for commit in above_anchor if not commit.has_from_nrf] + return ChipSyncReport( + master_sha=master_sha, + sdk_nrf_branch_sha=sdk_nrf_sha, + merge_base_sha=anchor_sha, + commits_above_anchor=above_anchor, + commits_missing_from_nrf=missing, + ) + + +def nrf_main_branch_sha(*, nrf_repo_name: str | None = None, branch: str = "main") -> str: + return branch_tip(repo=nrf_repo_name or nrf_repo(), branch=branch) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--chip-repo", default=None) + parser.add_argument("--nrf-repo", default=None) + parser.add_argument("--nrf-branch", default="main") + parser.add_argument("--state-file", type=Path, required=True) + args = parser.parse_args() + + chip_repo_name = args.chip_repo or chip_repo() + nrf_repo_name = args.nrf_repo or nrf_repo() + chip_report = build_chip_sync_report(chip_repo_name=chip_repo_name) + nrf_sha = nrf_main_branch_sha(nrf_repo_name=nrf_repo_name, branch=args.nrf_branch) + + if args.state_file.exists(): + state = read_json(args.state_file) + else: + state = {} + + state["chip_sync"] = chip_report.to_dict() + state["matter_sha"] = chip_report.sdk_nrf_branch_sha + state["nrf_sha"] = nrf_sha + write_json(args.state_file, state) + + print(f"sdk-connectedhomeip master: {chip_report.master_sha}") + print(f"sdk-connectedhomeip sdk-nrf: {chip_report.sdk_nrf_branch_sha}") + print(f"merge-base anchor: {chip_report.merge_base_sha}") + print(f"commits above anchor: {len(chip_report.commits_above_anchor)}") + print(f"commits missing {FROM_NRF_MARKER}: {len(chip_report.commits_missing_from_nrf)}") + print(f"sdk-nrf {args.nrf_branch}: {nrf_sha}") + + +if __name__ == "__main__": + main() diff --git a/scripts/sdk_sync/common.py b/scripts/sdk_sync/common.py new file mode 100644 index 00000000..a7705c86 --- /dev/null +++ b/scripts/sdk_sync/common.py @@ -0,0 +1,179 @@ +# Copyright (c) 2026 Nordic Semiconductor ASA +# SPDX-License-Identifier: Apache-2.0 + +"""Shared helpers for sdk_sync scripts.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import requests + +GITHUB_API = "https://api.github.com" +FROM_NRF_MARKER = "[from nrf]" +REPORT_MARKER = "" + +DEFAULT_NRF_REPO = "nrfconnect/sdk-nrf" +DEFAULT_CHIP_REPO = "nrfconnect/sdk-connectedhomeip" + + +def env_repo(name: str, default: str) -> str: + return os.environ.get(name, default) + + +def nrf_repo() -> str: + return env_repo("SDK_SYNC_NRF_REPO", DEFAULT_NRF_REPO) + + +def chip_repo() -> str: + return env_repo("SDK_SYNC_CHIP_REPO", DEFAULT_CHIP_REPO) + + +@dataclass +class CommitInfo: + sha: str + subject: str + html_url: str + has_from_nrf: bool + + +@dataclass +class ChipSyncReport: + master_sha: str + sdk_nrf_branch_sha: str + merge_base_sha: str + commits_above_anchor: list[CommitInfo] + commits_missing_from_nrf: list[CommitInfo] + + def to_dict(self) -> dict[str, Any]: + return { + "master_sha": self.master_sha, + "sdk_nrf_branch_sha": self.sdk_nrf_branch_sha, + "merge_base_sha": self.merge_base_sha, + "commits_above_anchor": [asdict(c) for c in self.commits_above_anchor], + "commits_missing_from_nrf": [asdict(c) for c in self.commits_missing_from_nrf], + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ChipSyncReport: + def _commits(key: str) -> list[CommitInfo]: + return [CommitInfo(**item) for item in data[key]] + + return cls( + master_sha=data["master_sha"], + sdk_nrf_branch_sha=data["sdk_nrf_branch_sha"], + merge_base_sha=data["merge_base_sha"], + commits_above_anchor=_commits("commits_above_anchor"), + commits_missing_from_nrf=_commits("commits_missing_from_nrf"), + ) + + +@dataclass +class SyncState: + nrf_sha: str + matter_sha: str + chip_sync: ChipSyncReport + pr_number: int + + def to_dict(self) -> dict[str, Any]: + return { + "nrf_sha": self.nrf_sha, + "matter_sha": self.matter_sha, + "chip_sync": self.chip_sync.to_dict(), + "pr_number": self.pr_number, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SyncState: + return cls( + nrf_sha=data["nrf_sha"], + matter_sha=data["matter_sha"], + chip_sync=ChipSyncReport.from_dict(data["chip_sync"]), + pr_number=data["pr_number"], + ) + + +def github_token() -> str: + token = os.environ.get("SDK_SYNC_PAT") or os.environ.get("GH_TOKEN") + if not token: + sys.exit("SDK_SYNC_PAT or GH_TOKEN must be set") + return token + + +def github_headers() -> dict[str, str]: + return { + "Authorization": f"Bearer {github_token()}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + +def github_get(path: str, *, params: dict[str, Any] | None = None) -> Any: + url = path if path.startswith("https://") else f"{GITHUB_API}{path}" + response = requests.get(url, headers=github_headers(), params=params, timeout=60) + response.raise_for_status() + return response.json() + + +def github_get_paginated(path: str, *, params: dict[str, Any] | None = None) -> list[Any]: + url = path if path.startswith("https://") else f"{GITHUB_API}{path}" + items: list[Any] = [] + while url: + response = requests.get(url, headers=github_headers(), params=params, timeout=60) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + return payload + items.extend(payload) + url = response.links.get("next", {}).get("url") + params = None + return items + + +def run_git(args: list[str], *, cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=cwd, + check=check, + text=True, + capture_output=True, + ) + + +def configure_git_user() -> None: + run_git(["config", "user.email", "sdk-sync-bot@users.noreply.github.com"]) + run_git(["config", "user.name", "sdk-sync-bot"]) + + +def short_sha(sha: str) -> str: + return sha[:12] + + +def write_json(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def parse_github_time(value: str | None) -> datetime | None: + if not value: + return None + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def within_last_days(value: str | None, days: int) -> bool: + parsed = parse_github_time(value) + if parsed is None: + return False + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + return parsed >= cutoff diff --git a/scripts/sdk_sync/prepare_pr.py b/scripts/sdk_sync/prepare_pr.py new file mode 100644 index 00000000..cfa01b2e --- /dev/null +++ b/scripts/sdk_sync/prepare_pr.py @@ -0,0 +1,182 @@ +# Copyright (c) 2026 Nordic Semiconductor ASA +# SPDX-License-Identifier: Apache-2.0 + +"""Prepare the sdk-sync test branch and open its pull request.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +import requests + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from common import configure_git_user, github_get, github_headers, run_git, write_json # noqa: E402 + + +def find_open_pr(*, repo: str, head_branch: str, base_branch: str) -> int | None: + pulls = github_get(f"/repos/{repo}/pulls", params={"state": "open", "per_page": 100}) + owner = repo.split("/", 1)[0] + head_ref = f"{owner}:{head_branch}" + for pull in pulls: + if pull.get("base", {}).get("ref") != base_branch: + continue + if pull.get("head", {}).get("ref") == head_branch or pull.get("head", {}).get("label") == head_ref: + return int(pull["number"]) + return None + + +def commits_ahead(*, repo: str, base_branch: str, head_branch: str) -> int: + comparison = github_get(f"/repos/{repo}/compare/{base_branch}...{head_branch}") + return int(comparison.get("ahead_by", 0)) + + +def prepare_pr_branch(*, source_branch: str, pr_branch: str, remote: str) -> None: + configure_git_user() + run_git(["fetch", remote, source_branch]) + run_git(["checkout", source_branch]) + run_git(["checkout", "-B", pr_branch]) + push = run_git(["push", "--force-with-lease", remote, pr_branch], check=False) + if push.returncode != 0: + print(push.stderr, file=sys.stderr) + sys.exit(f"Failed to push branch {pr_branch}") + + +def ensure_repo_label(*, repo: str, label: str) -> None: + encoded = requests.utils.quote(label, safe="") + response = requests.get( + f"https://api.github.com/repos/{repo}/labels/{encoded}", + headers=github_headers(), + timeout=60, + ) + if response.status_code == 200: + return + create = requests.post( + f"https://api.github.com/repos/{repo}/labels", + headers=github_headers(), + json={ + "name": label, + "color": "0E8A16", + "description": "Skip Jenkins additional/full Twister (sdk-sync PR)", + }, + timeout=60, + ) + if create.status_code >= 400 and create.status_code != 422: + sys.exit(f"Failed to create label {label!r}: {create.status_code} {create.text}") + + +def ensure_pr_label(*, repo: str, pr_number: int, label: str) -> None: + ensure_repo_label(repo=repo, label=label) + response = requests.post( + f"https://api.github.com/repos/{repo}/issues/{pr_number}/labels", + headers=github_headers(), + json=[label], + timeout=60, + ) + if response.status_code >= 400: + sys.exit(f"Failed to add label {label!r} to PR #{pr_number}: {response.status_code} {response.text}") + print(f"Ensured label {label!r} on PR #{pr_number}") + + +def open_sync_pr( + *, + repo: str, + base_branch: str, + head_branch: str, + state_file: Path, + pr_label: str | None = "ci-disabled", +) -> int | None: + ahead = commits_ahead(repo=repo, base_branch=base_branch, head_branch=head_branch) + if ahead == 0: + existing = find_open_pr(repo=repo, head_branch=head_branch, base_branch=base_branch) + if existing is not None: + print(f"No new commits, reusing open PR #{existing}") + if pr_label: + ensure_pr_label(repo=repo, pr_number=existing, label=pr_label) + return existing + print(f"No commits between {base_branch} and {head_branch}; skipping PR creation") + return None + + existing = find_open_pr(repo=repo, head_branch=head_branch, base_branch=base_branch) + if existing is not None: + print(f"Reusing open PR #{existing} for {head_branch} -> {base_branch} ({ahead} commits ahead)") + if pr_label: + ensure_pr_label(repo=repo, pr_number=existing, label=pr_label) + return existing + + title = "manifest: Weekly sdk-nrf / sdk-connectedhomeip sync test" + body = ( + "Automated weekly sync PR testing updated `sdk-nrf` and " + "`sdk-connectedhomeip` revisions in `west.yml`.\n\n" + "Do not merge without review.\n\n" + "" + ) + response = requests.post( + f"https://api.github.com/repos/{repo}/pulls", + headers=github_headers(), + json={ + "title": title, + "head": head_branch, + "base": base_branch, + "body": body, + }, + timeout=60, + ) + if response.status_code >= 400: + sys.exit(f"Failed to create PR: {response.status_code} {response.text}") + number = int(response.json()["number"]) + print(f"Created PR #{number} ({head_branch} -> {base_branch}, {ahead} commits ahead)") + if pr_label: + ensure_pr_label(repo=repo, pr_number=number, label=pr_label) + return number + + +def store_pr_number(*, state_file: Path, pr_number: int | None) -> None: + if pr_number is None: + return + if state_file.exists(): + state = json.loads(state_file.read_text(encoding="utf-8")) + else: + state = {} + state["pr_number"] = pr_number + write_json(state_file, state) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "nrfconnect/ncs-matter")) + parser.add_argument("--command", choices=["prepare-branch", "open-pr"], required=True) + parser.add_argument("--source-branch", default="sdk-nrf") + parser.add_argument("--pr-branch", default="sdk-sync/test") + parser.add_argument("--base-branch", default="sdk-nrf") + parser.add_argument("--remote", default="origin") + parser.add_argument("--pr-label", default="ci-disabled") + parser.add_argument("--state-file", type=Path, required=True) + args = parser.parse_args() + + if args.command == "prepare-branch": + prepare_pr_branch( + source_branch=args.source_branch, + pr_branch=args.pr_branch, + remote=args.remote, + ) + return + + pr_number = open_sync_pr( + repo=args.repo, + base_branch=args.base_branch, + head_branch=args.pr_branch, + state_file=args.state_file, + pr_label=args.pr_label or None, + ) + store_pr_number(state_file=args.state_file, pr_number=pr_number) + + +if __name__ == "__main__": + main() diff --git a/scripts/sdk_sync/requirements.txt b/scripts/sdk_sync/requirements.txt new file mode 100644 index 00000000..d33e915e --- /dev/null +++ b/scripts/sdk_sync/requirements.txt @@ -0,0 +1,2 @@ +# Python dependencies for scripts/sdk_sync (weekly SDK sync workflow). +requests diff --git a/scripts/sdk_sync/sync_chip_branch.py b/scripts/sdk_sync/sync_chip_branch.py new file mode 100644 index 00000000..b5159744 --- /dev/null +++ b/scripts/sdk_sync/sync_chip_branch.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026 Nordic Semiconductor ASA +# SPDX-License-Identifier: Apache-2.0 + +"""Rebase sdk-connectedhomeip sdk-nrf branch onto master.""" + +from __future__ import annotations + +import argparse +import shutil +import sys +import tempfile +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from common import chip_repo, configure_git_user, github_token, run_git # noqa: E402 + + +def sync_chip_branch( + *, + chip_repo_name: str, + base_branch: str, + integration_branch: str, +) -> None: + configure_git_user() + token = github_token() + workdir = Path(tempfile.mkdtemp(prefix="sdk-connectedhomeip-sync-")) + clone_url = f"https://x-access-token:{token}@github.com/{chip_repo_name}.git" + + try: + clone = run_git(["clone", clone_url, str(workdir)], check=False) + if clone.returncode != 0: + print(clone.stderr, file=sys.stderr) + sys.exit(f"Failed to clone {chip_repo_name}") + + run_git(["fetch", "origin", base_branch, integration_branch], cwd=workdir) + + checkout = run_git(["checkout", integration_branch], cwd=workdir, check=False) + if checkout.returncode != 0: + run_git(["checkout", "-B", integration_branch, f"origin/{integration_branch}"], cwd=workdir) + + rebase = run_git(["rebase", f"origin/{base_branch}"], cwd=workdir, check=False) + if rebase.returncode != 0: + run_git(["rebase", "--abort"], cwd=workdir, check=False) + print(rebase.stderr, file=sys.stderr) + sys.exit( + f"Rebase of {chip_repo_name}:{integration_branch} onto {base_branch} failed" + ) + + push = run_git(["push", "--force-with-lease", "origin", integration_branch], cwd=workdir, check=False) + if push.returncode != 0: + print(push.stderr, file=sys.stderr) + sys.exit(f"Failed to push rebased {integration_branch} to {chip_repo_name}") + finally: + shutil.rmtree(workdir, ignore_errors=True) + + print(f"Rebased {chip_repo_name}:{integration_branch} onto {base_branch} and pushed") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--chip-repo", default=None) + parser.add_argument("--base-branch", default="master") + parser.add_argument("--integration-branch", default="sdk-nrf") + args = parser.parse_args() + sync_chip_branch( + chip_repo_name=args.chip_repo or chip_repo(), + base_branch=args.base_branch, + integration_branch=args.integration_branch, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/sdk_sync/sync_integration_branch.py b/scripts/sdk_sync/sync_integration_branch.py new file mode 100644 index 00000000..1f69b193 --- /dev/null +++ b/scripts/sdk_sync/sync_integration_branch.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026 Nordic Semiconductor ASA +# SPDX-License-Identifier: Apache-2.0 + +"""Synchronize the integration branch with main.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from common import configure_git_user, run_git # noqa: E402 + + +def sync_branch(*, main_branch: str, integration_branch: str, remote: str) -> None: + configure_git_user() + run_git(["fetch", remote, main_branch, integration_branch]) + + run_git(["checkout", "-B", integration_branch, f"{remote}/{main_branch}"]) + push = run_git(["push", "--force-with-lease", remote, integration_branch], check=False) + if push.returncode != 0: + print(push.stderr, file=sys.stderr) + sys.exit(f"Failed to push synchronized {integration_branch} to {remote}") + + print(f"Synchronized {integration_branch} with {remote}/{main_branch}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--main-branch", default="main") + parser.add_argument("--integration-branch", default="sdk-nrf") + parser.add_argument("--remote", default="origin") + args = parser.parse_args() + sync_branch( + main_branch=args.main_branch, + integration_branch=args.integration_branch, + remote=args.remote, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/sdk_sync/update_manifest.py b/scripts/sdk_sync/update_manifest.py new file mode 100644 index 00000000..8f01620f --- /dev/null +++ b/scripts/sdk_sync/update_manifest.py @@ -0,0 +1,117 @@ +# Copyright (c) 2026 Nordic Semiconductor ASA +# SPDX-License-Identifier: Apache-2.0 + +"""Update west.yml with new sdk-nrf and sdk-connectedhomeip revisions.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from common import configure_git_user, read_json, run_git # noqa: E402 + +REVISION_LINE = re.compile(r"^(\s*)revision:\s*(.+?)\s*$") + + +def project_revisions(text: str) -> dict[str, str]: + revisions: dict[str, str] = {} + current_project: str | None = None + for line in text.splitlines(): + stripped = line.lstrip() + if stripped.startswith("- name: "): + current_project = stripped.removeprefix("- name: ").strip() + continue + match = REVISION_LINE.match(line) + if match and current_project in {"nrf", "matter"}: + revisions[current_project] = match.group(2).strip() + return revisions + + +def update_manifest_text(*, text: str, nrf_sha: str, matter_sha: str) -> str: + current_project: str | None = None + updated_lines: list[str] = [] + + for line in text.splitlines(keepends=True): + stripped = line.lstrip() + if stripped.startswith("- name: "): + current_project = stripped.removeprefix("- name: ").strip() + + match = REVISION_LINE.match(line.rstrip("\n")) + if match and current_project in {"nrf", "matter"}: + indent = match.group(1) + new_revision = nrf_sha if current_project == "nrf" else matter_sha + newline = "\n" if line.endswith("\n") else "" + updated_lines.append(f"{indent}revision: {new_revision}{newline}") + continue + + updated_lines.append(line) + + return "".join(updated_lines) + + +def update_manifest_file(*, west_yml: Path, nrf_sha: str, matter_sha: str) -> bool: + original = west_yml.read_text(encoding="utf-8") + revisions = project_revisions(original) + old_nrf = revisions.get("nrf") + old_matter = revisions.get("matter") + + if old_nrf == nrf_sha and old_matter == matter_sha: + return False + + updated = update_manifest_text(text=original, nrf_sha=nrf_sha, matter_sha=matter_sha) + west_yml.write_text(updated, encoding="utf-8") + return True + + +def commit_and_push(*, west_yml: Path, branch: str, nrf_sha: str, matter_sha: str) -> None: + configure_git_user() + run_git(["add", str(west_yml)]) + message = ( + "manifest: Bump nrf/matter revisions for weekly sync\n\n" + f"nrf revision: {nrf_sha}\n" + f"matter revision: {matter_sha}\n" + ) + run_git(["commit", "-m", message]) + push = run_git(["push", "origin", branch], check=False) + if push.returncode != 0: + print(push.stderr, file=sys.stderr) + sys.exit(f"Failed to push manifest update to {branch}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--west-yml", type=Path, default=Path("west.yml")) + parser.add_argument("--state-file", type=Path, required=True) + parser.add_argument("--branch", default="sdk-sync/test") + args = parser.parse_args() + + state = read_json(args.state_file) + nrf_sha = state["nrf_sha"] + matter_sha = state["matter_sha"] + + changed = update_manifest_file( + west_yml=args.west_yml, + nrf_sha=nrf_sha, + matter_sha=matter_sha, + ) + if not changed: + print("west.yml already points at the requested revisions") + return + + commit_and_push( + west_yml=args.west_yml, + branch=args.branch, + nrf_sha=nrf_sha, + matter_sha=matter_sha, + ) + print("Updated west.yml and pushed manifest bump commit") + + +if __name__ == "__main__": + main() diff --git a/west.yml b/west.yml index 66038a33..6a94bdfc 100644 --- a/west.yml +++ b/west.yml @@ -10,13 +10,13 @@ manifest: - name: nrf remote: ncs repo-path: sdk-nrf - revision: v3.4.0 + revision: 1f84a13dc612210bdcad81a393f7cd003f8b1396 import: true - name: matter remote: ncs path: modules/lib/matter repo-path: sdk-connectedhomeip - revision: 656bfc238b3efff447ee09d384309be21e108bde + revision: cb67e25f060ad3c9b2c5c95ff58a08b12216a986 submodules: - name: nlio path: third_party/nlio/repo