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