From c0329c8e450a213455c28f7c7f4aa27877475cab Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 15 Aug 2026 17:43:19 +0800 Subject: [PATCH 1/6] chore: add esp32h2 to init chip options, bump to 0.1.0 rmk-template gained esp32h2 and esp32h2_split templates, but 'rmkit init' had no way to pick them. 0.1.0 rather than 0.0.22: the layout subcommands are a new surface, and the 0.0.x series predates the rmk-rs migration. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/chip.rs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cd4992..a66c79b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1785,7 +1785,7 @@ dependencies = [ [[package]] name = "rmkit" -version = "0.0.21" +version = "0.1.0" dependencies = [ "cargo_metadata", "cargo_toml", diff --git a/Cargo.toml b/Cargo.toml index 0b73b62..970a617 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rmkit" -version = "0.0.21" +version = "0.1.0" edition = "2021" homepage = "https://github.com/rmk-rs/rmkit" repository = "https://github.com/rmk-rs/rmkit" diff --git a/src/chip.rs b/src/chip.rs index 84bc386..e558580 100644 --- a/src/chip.rs +++ b/src/chip.rs @@ -27,6 +27,7 @@ pub(crate) fn get_chip_options(split: bool) -> Vec<&'static str> { "Pi Pico W", "esp32c3", "esp32c6", + "esp32h2", "esp32s3", ] } else { @@ -41,6 +42,7 @@ pub(crate) fn get_chip_options(split: bool) -> Vec<&'static str> { "esp32c3", "esp32s3", "esp32c6", + "esp32h2", "nice!nano_v2", "XIAO BLE", "nice!nano", From 9286a6192afe48827e3d3e376cf8b19e5d1570f4 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Mon, 17 Aug 2026 11:17:59 +0800 Subject: [PATCH 2/6] feat(create): use the user's Cargo.toml / memory.x next to keyboard.toml as-is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `Cargo.toml` or `memory.x` sitting next to keyboard.toml replaces the template's copy verbatim, so a project can add rmk features, pin dependencies, or change the flash layout without touching the template or the cloud build workflow. When the user provides Cargo.toml, rmkit no longer adjusts the rmk feature list โ€” keyboard.toml/feature mismatches are reported by rmk-macro at build time. Claude-Session: https://claude.ai/code/session_01YPLK88rkh5jqoHmRUdAvUN --- README.md | 2 ++ src/args.rs | 3 ++- src/main.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e54173b..87224bc 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Now rmkit can be used to generate RMK project directly from `keyboard.toml` and rmkit create --keyboard-toml-path keyboard.toml --vial-json-path vial.json ``` + A `Cargo.toml` or `memory.x` next to `keyboard.toml` replaces the template's copy as-is โ€” use it to add Cargo features, pin dependencies, or change the flash layout. When you provide `Cargo.toml`, rmkit no longer adjusts the `rmk` features for you. + 3. Or, you can create RMK project from project template ``` diff --git a/src/args.rs b/src/args.rs index 0f433bf..8d8eec8 100644 --- a/src/args.rs +++ b/src/args.rs @@ -11,7 +11,8 @@ pub struct Args { pub enum Commands { /// Create a new RMK project from keyboard.toml and vial.json Create { - /// Path to keyboard.toml file + /// Path to keyboard.toml file. A Cargo.toml or memory.x next to it + /// replaces the template's copy verbatim. #[arg(long)] keyboard_toml_path: Option, diff --git a/src/main.rs b/src/main.rs index 56e108d..80a7cf7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -106,14 +106,42 @@ async fn create_project( )?; fs::copy(&vial_json_path, project_info.target_dir.join("vial.json"))?; + // A Cargo.toml / memory.x next to keyboard.toml is the user's, verbatim + let user_dir = Path::new(&keyboard_toml_path) + .parent() + .unwrap_or(Path::new("")); + let cargo_toml_user_owned = copy_user_owned_files(user_dir, &project_info.target_dir)?; + // Post-process - post_process(project_info)?; + post_process(project_info, cargo_toml_user_owned)?; Ok(()) } +/// Files that replace the template's copy when they sit next to keyboard.toml +const USER_OWNED_FILES: [&str; 2] = ["Cargo.toml", "memory.x"]; + +/// Copy the user's own project files over the generated project. Returns whether +/// Cargo.toml was among them โ€” the user then owns the feature list too. +fn copy_user_owned_files(user_dir: &Path, target_dir: &Path) -> Result> { + let mut cargo_toml_user_owned = false; + for name in USER_OWNED_FILES { + let src = user_dir.join(name); + if !src.is_file() { + continue; + } + fs::copy(&src, target_dir.join(name))?; + println!("๐Ÿ“„ Using {} (replaces the template's)", src.display()); + cargo_toml_user_owned |= name == "Cargo.toml"; + } + Ok(cargo_toml_user_owned) +} + /// Postprocessing after generating project -fn post_process(project_info: ProjectInfo) -> Result<(), Box> { +fn post_process( + project_info: ProjectInfo, + cargo_toml_user_owned: bool, +) -> Result<(), Box> { // Replace {{ project_name }} in toml/json files replace_in_folder( &project_info, @@ -139,6 +167,13 @@ fn post_process(project_info: ProjectInfo) -> Result<(), Box> { &project_info.uf2_key, )?; + // The user's Cargo.toml is used as-is; keyboard.toml/feature mismatches are + // reported by rmk-macro at build time. + if cargo_toml_user_owned { + println!("Skipping rmk feature adjustments: Cargo.toml is user-provided"); + return Ok(()); + } + // Disable some default features if !project_info.disabled_default_feature.is_empty() { let metadata = MetadataCommand::new() @@ -279,7 +314,7 @@ async fn init_project( } // Post-process - post_process(project_info)?; + post_process(project_info, false)?; Ok(()) } @@ -596,3 +631,36 @@ fn enable_rmk_features(target_dir: &PathBuf, features: Vec) -> Result<() Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_owned_files_replace_template_copies() { + let root = std::env::temp_dir().join(format!("rmkit_user_owned_{}", std::process::id())); + let (user_dir, target_dir) = (root.join("user"), root.join("target")); + fs::create_dir_all(&user_dir).unwrap(); + fs::create_dir_all(&target_dir).unwrap(); + fs::write(target_dir.join("Cargo.toml"), "template").unwrap(); + fs::write(target_dir.join("memory.x"), "template").unwrap(); + let generated = |name: &str| fs::read_to_string(target_dir.join(name)).unwrap(); + + // Nothing next to keyboard.toml: template files stay, Cargo.toml is not user-owned + assert!(!copy_user_owned_files(&user_dir, &target_dir).unwrap()); + assert_eq!(generated("Cargo.toml"), "template"); + + // Only memory.x provided: it replaces the template's, Cargo.toml is still rmkit's + fs::write(user_dir.join("memory.x"), "user").unwrap(); + assert!(!copy_user_owned_files(&user_dir, &target_dir).unwrap()); + assert_eq!(generated("memory.x"), "user"); + assert_eq!(generated("Cargo.toml"), "template"); + + // Cargo.toml provided: replaced verbatim and reported as user-owned + fs::write(user_dir.join("Cargo.toml"), "user").unwrap(); + assert!(copy_user_owned_files(&user_dir, &target_dir).unwrap()); + assert_eq!(generated("Cargo.toml"), "user"); + + fs::remove_dir_all(&root).unwrap(); + } +} From 68763a7be6383c6a8a674220fc02b2d562d4a920 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Thu, 27 Aug 2026 01:00:58 +0800 Subject: [PATCH 3/6] fix: depend on published crates, and drop chips with no template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `rmk-config` and `rynk-kle` were git dependencies, which blocks `cargo publish` outright โ€” 0.1.0 could not have shipped as it stood. Both are on crates.io now (rmk-config 0.7.0, rynk-kle 0.2.0), so point at the published versions. Record in the manifest why the rmk-config version is coupled to the keyboard.toml schema: the config structs are `deny_unknown_fields`, so they reject keys added by later versions and the pin has to move with each rmk release. Checking the chip list against rmk-template also turned up three options that cannot work: `nrf52833`, `nrf52811` and `nrf52810` have no template at all, so choosing one fails with "The specified chip/board does not exist in the template repo". Unlike stm32 there is no family fallback, so stop advertising them. Also move off the yanked rust-ini 0.21.2. Signed-off-by: Haobo Gu Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 12 ++++++++---- Cargo.toml | 9 +++++---- src/chip.rs | 3 --- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a66c79b..c7a51b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1754,7 +1754,8 @@ dependencies = [ [[package]] name = "rmk-config" version = "0.7.0" -source = "git+https://github.com/rmk-rs/rmk?rev=93726512f2fb383cdb5a419b09ff181c7c123b5f#93726512f2fb383cdb5a419b09ff181c7c123b5f" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf80b83061584d792b01687f3f1b002bbf65f158db5833316471789265caac6" dependencies = [ "config", "miniz_oxide 0.9.1", @@ -1771,7 +1772,8 @@ dependencies = [ [[package]] name = "rmk-types" version = "0.3.0" -source = "git+https://github.com/rmk-rs/rmk?rev=93726512f2fb383cdb5a419b09ff181c7c123b5f#93726512f2fb383cdb5a419b09ff181c7c123b5f" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7172f033549955a92fa2cb35f6c16ee45cfd77eacc3e251fe44137305851cd83" dependencies = [ "bitfield-struct", "cobs", @@ -1879,7 +1881,8 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rynk" version = "0.2.0" -source = "git+https://github.com/rmk-rs/rmk?rev=93726512f2fb383cdb5a419b09ff181c7c123b5f#93726512f2fb383cdb5a419b09ff181c7c123b5f" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "725415914d7f93aeeb878eae096b6bdafb590b3e8e6c70ebdb54874733c2d274" dependencies = [ "critical-section", "embassy-futures", @@ -1897,7 +1900,8 @@ dependencies = [ [[package]] name = "rynk-kle" version = "0.2.0" -source = "git+https://github.com/rmk-rs/rmk?rev=93726512f2fb383cdb5a419b09ff181c7c123b5f#93726512f2fb383cdb5a419b09ff181c7c123b5f" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8f1d72056be61f44a372336360cde3f77fb815d4c6860f9ec3199697138e521" dependencies = [ "kle-serial", "rmk-config", diff --git a/Cargo.toml b/Cargo.toml index 970a617..21c0dd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,11 +9,12 @@ description = "rmkit is a toolkit set for RMK keyboard firmware" license = "Apache-2.0" [dependencies] -# TODO: back to crates.io versions (rmk-config = "0.7", rynk-kle) once they are -# published โ€” a git dependency cannot be published. -rmk-config = { git = "https://github.com/rmk-rs/rmk", rev = "93726512f2fb383cdb5a419b09ff181c7c123b5f" } +# Pins the `keyboard.toml` schema rmkit understands: 0.7.0 is the config crate +# RMK 0.9.0 ships. Bump it in lockstep with each RMK release, since the structs +# are `deny_unknown_fields` and reject keys added by later versions. +rmk-config = "0.7.0" # The KLE/Vial โ†” [layout] conversion engine; `rmkit layout` is CLI glue over it. -rynk-kle = { git = "https://github.com/rmk-rs/rmk", rev = "93726512f2fb383cdb5a419b09ff181c7c123b5f" } +rynk-kle = "0.2.0" clap = { version = "4.5.23", features = ["derive", "string"] } toml = "0.9.8" serde = "1.0" diff --git a/src/chip.rs b/src/chip.rs index e558580..570b3e3 100644 --- a/src/chip.rs +++ b/src/chip.rs @@ -35,10 +35,7 @@ pub(crate) fn get_chip_options(split: bool) -> Vec<&'static str> { "nrf52840", "rp2040", "Pi Pico W", - "nrf52833", "nrf52832", - "nrf52811", - "nrf52810", "esp32c3", "esp32s3", "esp32c6", From 10fde81507d0233e6b3a521ed219341b7645f7a3 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Thu, 27 Aug 2026 01:01:11 +0800 Subject: [PATCH 4/6] feat: add CI and a daily upstream drift check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rmkit had no CI beyond cargo-dist's release workflow, and it hardcodes assumptions about rmk that nothing verifies: the keyboard.toml schema it parses, the cargo feature names it writes into generated projects, and the chips it offers. Those rot silently whenever rmk moves โ€” 89c4796 was cleaning up exactly that kind of rot, long after the fact. `ci.yml` runs fmt, clippy and a locked build on every PR, plus a `publishable` job that rejects git dependencies in the lockfile and runs `cargo publish --dry-run`. That trap already cost 0.0.22 a release cycle and only surfaces at release time otherwise. `scripts/check_upstream_drift.py` compares rmkit against rmk on two axes: release crates.io rmkit vs crates.io rmk main the working tree vs rmk's main branch They answer different questions โ€” whether what users can install today works with the RMK they can install today, versus whether the next release will. The release axis reports but never gates, because nothing in the working tree changes its verdict; the only fix is to cut a release. Findings are cross-referenced between the axes so each one says whether it is already fixed on main or still live. It checks the rmk-config version requirement, the emitted feature names against rmk's `[features]`, parsing of every `examples/use_config` keyboard.toml with the real binary, the chip list against rmk-template, and version-mapping.json coverage. Feature names and chip options are extracted from rmkit's own source rather than copied into the script, so the checker cannot quietly fall out of sync with what it checks; a refactor that breaks an anchor raises instead. `upstream-drift.yml` runs it daily, keeping one always-current issue open while drift persists and closing it when it clears. It deliberately does not run on pull requests: it can go red because upstream changed overnight, which says nothing about the PR under review. The version.rs change is the one rustfmt violation the new fmt gate would otherwise trip on. Signed-off-by: Haobo Gu Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 62 +++ .github/workflows/upstream-drift.yml | 89 +++++ scripts/check_upstream_drift.py | 558 +++++++++++++++++++++++++++ src/version.rs | 2 +- 4 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/upstream-drift.yml create mode 100644 scripts/check_upstream_drift.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3e687a6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: fmt ยท clippy ยท build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: rustfmt + run: cargo fmt --check + + - name: clippy + run: cargo clippy --all-targets -- -D warnings + + - name: build + run: cargo build --locked + + - name: Smoke-test the bundled keyboard.toml + run: | + set -euo pipefail + test "$(cargo run --quiet -- get-chip --keyboard-toml-path keyboard.toml)" = "nrf52840" + test "$(cargo run --quiet -- get-project-name --keyboard-toml-path keyboard.toml)" = "RMK_Keyboard" + + publishable: + name: publishable + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + # A git dependency silently blocks `cargo publish`, and the failure only + # surfaces at release time. Catch it on every PR instead. + - name: Reject git dependencies + run: | + if grep -q 'source = "git+' Cargo.lock; then + echo "::error::Cargo.lock contains a git dependency; rmkit cannot be published." + grep -n 'source = "git+' Cargo.lock + exit 1 + fi + + - name: cargo publish --dry-run + run: cargo publish --dry-run --locked diff --git a/.github/workflows/upstream-drift.yml b/.github/workflows/upstream-drift.yml new file mode 100644 index 0000000..d87a180 --- /dev/null +++ b/.github/workflows/upstream-drift.yml @@ -0,0 +1,89 @@ +name: Upstream drift + +# rmkit hardcodes a handful of assumptions about RMK: the keyboard.toml schema +# it parses, the cargo feature names it writes into generated projects, and the +# chips it offers. None of that is enforced by the compiler, so it rots +# silently whenever RMK moves. This job checks those assumptions against +# upstream every day and files an issue when they stop holding. +# +# It deliberately does not run on pull requests: it can go red because upstream +# changed overnight, which has nothing to do with the PR under review. Add +# `pull_request:` below if you would rather gate on it. + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +env: + ISSUE_LABEL: upstream-drift + ISSUE_TITLE: "Upstream drift: rmkit's assumptions about RMK no longer hold" + +jobs: + drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Check for drift + id: check + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -o pipefail + python3 scripts/check_upstream_drift.py 2>&1 | tee report.txt + + - name: Compose issue body + if: steps.check.outcome == 'failure' + run: | + { + echo "The daily upstream check found that rmkit no longer matches RMK." + echo + echo "\`\`\`" + cat report.txt + echo "\`\`\`" + echo + echo "Reproduce locally with \`python3 scripts/check_upstream_drift.py\`." + echo + echo "_Updated by [\`${GITHUB_WORKFLOW}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})._" + } > body.md + + - name: Open or update the drift issue + if: steps.check.outcome == 'failure' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh label create "$ISSUE_LABEL" --description "rmkit has drifted from upstream RMK" --color D93F0B 2>/dev/null || true + existing=$(gh issue list --label "$ISSUE_LABEL" --state open --limit 1 --json number --jq '.[0].number // empty') + if [ -n "$existing" ]; then + # Keep a single always-current issue rather than commenting daily. + gh issue edit "$existing" --body-file body.md + echo "Updated issue #$existing" + else + gh issue create --title "$ISSUE_TITLE" --label "$ISSUE_LABEL" --body-file body.md + fi + + - name: Close the drift issue once it clears + if: steps.check.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + existing=$(gh issue list --label "$ISSUE_LABEL" --state open --limit 1 --json number --jq '.[0].number // empty') + if [ -n "$existing" ]; then + gh issue close "$existing" --comment "rmkit matches upstream again; closed automatically." + fi + + - name: Propagate the result + if: steps.check.outcome == 'failure' + run: exit 1 diff --git a/scripts/check_upstream_drift.py b/scripts/check_upstream_drift.py new file mode 100644 index 0000000..7b86402 --- /dev/null +++ b/scripts/check_upstream_drift.py @@ -0,0 +1,558 @@ +#!/usr/bin/env python3 +"""Detect drift between rmkit and upstream RMK. + +Two axes, checked independently: + + release rmkit's latest crates.io release vs rmk's latest crates.io release + main rmkit's working tree vs rmk's `main` branch + +They answer different questions and drift for different reasons. The release +axis asks whether what users can install today works with the RMK they can +install today. The main axis asks whether the *next* rmkit release will work +with the *next* RMK release. A green release axis and a red main axis is the +normal state while RMK has unreleased changes. + +Stdlib only (urllib + tomllib), so it runs on a bare CI image and locally with +no setup. Python >= 3.11. + +Usage: + python3 scripts/check_upstream_drift.py # both axes + python3 scripts/check_upstream_drift.py --axis main + python3 scripts/check_upstream_drift.py --format json +""" + +from __future__ import annotations + +import argparse +import io +import json +import os +import re +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +RMK_REPO = "rmk-rs/rmk" +RMKIT_REPO = "rmk-rs/rmkit" +TEMPLATE_REPO = "rmk-rs/rmk-template" + +USER_AGENT = "rmkit-upstream-drift-check" + +# Example keyboard.toml files that rmkit is not expected to parse. Add entries +# here (with a reason) rather than weakening the check. +EXPECTED_PARSE_FAILURES: dict[str, str] = {} + + +# -------------------------------------------------------------------------- +# Findings +# -------------------------------------------------------------------------- + + +@dataclass +class Finding: + level: str # "fail" | "warn" | "info" + check: str + message: str + + +@dataclass +class Report: + axis: str + # Whether this axis can fail the run. The release axis describes artifacts + # that are already published: nothing you do in the working tree changes + # its verdict, so it reports but never gates. + gating: bool = True + findings: list[Finding] = field(default_factory=list) + + def fail(self, check: str, message: str) -> None: + self.findings.append(Finding("fail", check, message)) + + def warn(self, check: str, message: str) -> None: + self.findings.append(Finding("warn", check, message)) + + def info(self, check: str, message: str) -> None: + self.findings.append(Finding("info", check, message)) + + @property + def failed(self) -> bool: + return any(f.level == "fail" for f in self.findings) + + +# -------------------------------------------------------------------------- +# Fetching +# -------------------------------------------------------------------------- + + +def http_get(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + token = os.environ.get("GITHUB_TOKEN") + if token and "github.com" in url: + request.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(request, timeout=120) as response: + return response.read() + + +def http_get_json(url: str): + return json.loads(http_get(url)) + + +def crates_io_newest(crate: str) -> str: + data = http_get_json(f"https://crates.io/api/v1/crates/{crate}") + return data["crate"]["newest_version"] + + +def crates_io_published_versions(crate: str) -> list[str]: + data = http_get_json(f"https://crates.io/api/v1/crates/{crate}") + return [v["num"] for v in data["versions"] if not v.get("yanked")] + + +def fetch_rmk_tree(ref: str, kind: str, dest: Path) -> Path: + """Download an rmk tree (branch or tag) and return its extracted root.""" + url = f"https://codeload.github.com/{RMK_REPO}/tar.gz/refs/{kind}/{ref}" + with tarfile.open(fileobj=io.BytesIO(http_get(url)), mode="r:gz") as archive: + archive.extractall(dest) + roots = [p for p in dest.iterdir() if p.is_dir()] + if len(roots) != 1: + raise RuntimeError(f"unexpected archive layout for {ref}: {roots}") + return roots[0] + + +def resolve_rmk_tag(version: str) -> str: + """RMK tagged releases as `v0.6.0` early on and `rmk-v0.8.2` since.""" + for candidate in (f"rmk-v{version}", f"v{version}"): + try: + http_get(f"https://api.github.com/repos/{RMK_REPO}/git/ref/tags/{candidate}") + return candidate + except urllib.error.HTTPError as err: + if err.code != 404: + raise + raise RuntimeError(f"no git tag found for rmk {version}") + + +# -------------------------------------------------------------------------- +# Reading rmkit's own assumptions out of its source +# -------------------------------------------------------------------------- +# +# These parse rmkit's Rust source rather than duplicating its constants here, +# so the checker cannot silently fall out of sync with the code it checks. If +# an anchor stops matching the extractor raises โ€” that is the intended +# behaviour, a refactor should force a look at this file. + + +def extract_rmk_config_req(cargo_toml: str) -> str: + manifest = tomllib.loads(cargo_toml) + dep = manifest["dependencies"]["rmk-config"] + if isinstance(dep, str): + return dep + if "version" in dep: + return dep["version"] + if "git" in dep: + return f"git:{dep.get('rev', dep.get('branch', 'HEAD'))}" + raise RuntimeError(f"cannot read an rmk-config version requirement from {dep!r}") + + +def extract_feature_names(keyboard_toml_rs: str) -> tuple[set[str], set[str]]: + """Return (features rmkit disables, features rmkit enables).""" + disabled = set(re.findall(r'disabled_default_feature\.push\("([^"]+)"', keyboard_toml_rs)) + enabled = set(re.findall(r'enabled_feature\.push\("([^"]+)"', keyboard_toml_rs)) + if not disabled: + raise RuntimeError("no `disabled_default_feature.push(..)` calls found in keyboard_toml.rs") + return disabled, enabled + + +def extract_chip_options(chip_rs: str) -> tuple[list[str], list[str]]: + """Return (split chip options, unibody chip options).""" + match = re.search( + r"fn get_chip_options.*?if split \{\s*vec!\[(.*?)\]\s*\} else \{\s*vec!\[(.*?)\]", + chip_rs, + re.DOTALL, + ) + if not match: + raise RuntimeError("could not locate the two vec![] blocks in get_chip_options") + return ( + re.findall(r'"([^"]+)"', match.group(1)), + re.findall(r'"([^"]+)"', match.group(2)), + ) + + +def extract_board_chip_map(chip_rs: str) -> dict[str, str]: + mapping = dict(re.findall(r'map\.insert\("([^"]+)",\s*"([^"]+)"\)', chip_rs)) + if not mapping: + raise RuntimeError("no `map.insert(..)` calls found in get_board_chip_map") + return mapping + + +# -------------------------------------------------------------------------- +# Cargo version requirements +# -------------------------------------------------------------------------- + + +def parse_version(version: str) -> tuple[int, int, int]: + core = version.split("-")[0].split("+")[0] + parts = [int(p) for p in core.split(".")] + while len(parts) < 3: + parts.append(0) + return parts[0], parts[1], parts[2] + + +def req_admits(req: str, version: str) -> bool: + """Whether a Cargo version requirement admits a concrete version. + + Only the forms RMK and rmkit actually use: `=X.Y.Z` and bare/caret. + """ + req = req.strip() + target = parse_version(version) + if req.startswith("="): + return parse_version(req[1:]) == target + bare = req.lstrip("^") + lower = parse_version(bare) + if target < lower: + return False + # Caret: the leftmost non-zero component may not change. + major, minor, _ = lower + if major > 0: + return target[0] == major + if minor > 0: + return target[0] == 0 and target[1] == minor + return target[0] == 0 and target[1] == 0 + + +# -------------------------------------------------------------------------- +# Upstream facts, per axis +# -------------------------------------------------------------------------- + + +@dataclass +class Upstream: + """What the rmk side of an axis looks like.""" + + label: str + rmk_version: str + rmk_config_version: str + rmk_types_version: str + features: dict[str, list[str]] + example_configs: list[Path] + + +def read_upstream(tree: Path, label: str) -> Upstream: + rmk_manifest = tomllib.loads((tree / "rmk" / "Cargo.toml").read_text()) + config_manifest = tomllib.loads((tree / "rmk-config" / "Cargo.toml").read_text()) + types_manifest = tomllib.loads((tree / "rmk-types" / "Cargo.toml").read_text()) + return Upstream( + label=label, + rmk_version=rmk_manifest["package"]["version"], + rmk_config_version=config_manifest["package"]["version"], + rmk_types_version=types_manifest["package"]["version"], + features=rmk_manifest.get("features", {}), + # `use_config` only: those are the full keyboard.toml files rmkit is + # built to consume. The `use_rust` examples carry partial configs with + # no `[keyboard]` section โ€” rmk's build.rs reads them through + # `new_from_toml_path_with_event_defaults`, and rmkit never sees them. + example_configs=sorted((tree / "examples" / "use_config").rglob("keyboard.toml")), + ) + + +# -------------------------------------------------------------------------- +# Checks +# -------------------------------------------------------------------------- + + +def check_versions(report: Report, rmk_config_req: str, upstream: Upstream) -> None: + report.info( + "versions", + f"rmk {upstream.rmk_version} ยท rmk-config {upstream.rmk_config_version} " + f"ยท rmk-types {upstream.rmk_types_version} ({upstream.label})", + ) + report.info("versions", f"rmkit requires rmk-config {rmk_config_req}") + + if rmk_config_req.startswith("git:"): + report.warn( + "versions", + f"rmkit depends on rmk-config via git ({rmk_config_req[4:]}); it cannot be " + "published in this state", + ) + return + + if not req_admits(rmk_config_req, upstream.rmk_config_version): + report.fail( + "versions", + f"rmkit requires rmk-config {rmk_config_req}, but {upstream.label} ships " + f"rmk-config {upstream.rmk_config_version} โ€” rmkit parses a different " + "keyboard.toml schema than the projects it generates", + ) + + +def check_features(report: Report, disabled: set[str], enabled: set[str], upstream: Upstream) -> None: + available = set(upstream.features) + defaults = set(upstream.features.get("default", [])) + + for name in sorted(disabled | enabled): + if name not in available: + report.fail( + "features", + f"rmkit emits the cargo feature `{name}`, which does not exist in " + f"{upstream.label} (rmk {upstream.rmk_version})", + ) + + # rmkit only removes names from the default set; one that is not a default + # is a silent no-op rather than an error, so it is a warning. + for name in sorted(disabled): + if name in available and name not in defaults: + report.warn( + "features", + f"rmkit tries to disable `{name}`, but it is not a default feature in " + f"{upstream.label} โ€” that disable is a no-op", + ) + + if not report.failed: + report.info("features", f"{len(disabled | enabled)} feature names verified against {upstream.label}") + + +def summarise_failure(output: str) -> str: + """Pull the useful line out of a Rust panic, not the `thread 'main'` banner.""" + for line in output.strip().splitlines(): + line = line.strip() + if not line or line.startswith(("thread '", "note:", "stack backtrace")): + continue + return line + return "no output" + + +def check_config_schema(report: Report, rmkit_bin: Path, upstream: Upstream) -> None: + """Parse every upstream example keyboard.toml with the real rmkit binary.""" + failures: list[str] = [] + checked = 0 + for config in upstream.example_configs: + name = str(config).split("/examples/", 1)[-1] + if name in EXPECTED_PARSE_FAILURES: + continue + checked += 1 + result = subprocess.run( + [str(rmkit_bin), "get-chip", "--keyboard-toml-path", str(config)], + capture_output=True, + text=True, + cwd=tempfile.gettempdir(), + ) + if result.returncode != 0 or not result.stdout.strip(): + failures.append(f"{name}: {summarise_failure(result.stderr or result.stdout)}") + + if failures: + report.fail( + "config-schema", + f"rmkit cannot parse {len(failures)}/{checked} example keyboard.toml files " + f"from {upstream.label}:\n " + "\n ".join(failures[:10]), + ) + else: + report.info("config-schema", f"parsed {checked} example keyboard.toml files from {upstream.label}") + + +def check_templates(report: Report, split_chips: list[str], unibody_chips: list[str], board_map: dict[str, str]) -> None: + listing = http_get_json(f"https://api.github.com/repos/{TEMPLATE_REPO}/contents/") + folders = {entry["name"] for entry in listing if entry["type"] == "dir" and not entry["name"].startswith(".")} + + def resolves(chip: str, split: bool) -> bool: + chip = board_map.get(chip, chip) + folder = f"{chip}_split" if split else chip + if folder in folders: + return True + # rmkit falls back to the stm32 family folder, then to plain `stm32`. + if folder.startswith("stm32"): + return folder[:7] in folders or "stm32" in folders + return False + + for chip in unibody_chips: + if not resolves(chip, split=False): + report.fail("templates", f"`rmkit init --chip {chip}` has no template in {TEMPLATE_REPO}") + for chip in split_chips: + if not resolves(chip, split=True): + report.fail("templates", f"`rmkit init --chip {chip} --split true` has no template in {TEMPLATE_REPO}") + + offered = {board_map.get(c, c) for c in unibody_chips} | {f"{board_map.get(c, c)}_split" for c in split_chips} + for folder in sorted(folders - offered): + if folder.startswith("stm32"): + continue # covered by the stm32 fallback, not enumerated + report.warn("templates", f"{TEMPLATE_REPO} has a `{folder}` template that `rmkit init` never offers") + + if not any(f.check == "templates" and f.level == "fail" for f in report.findings): + report.info("templates", f"{len(split_chips) + len(unibody_chips)} chip options resolve to a template") + + +def check_version_mapping(report: Report) -> None: + mapping = http_get_json( + f"https://raw.githubusercontent.com/{TEMPLATE_REPO}/main/version-mapping.json" + ) + published = {".".join(v.split(".")[:2]) for v in crates_io_published_versions("rmk")} + # Only minors newer than the newest mapped one matter. Everything older + # predates the mapping scheme and is never getting an entry. + floor = max((parse_version(v) for v in mapping), default=(0, 0, 0)) + missing = sorted( + (v for v in published - set(mapping) if parse_version(v) > floor), + key=parse_version, + ) + if missing: + report.warn( + "version-mapping", + f"{TEMPLATE_REPO}/version-mapping.json has no entry for published rmk " + f"{', '.join(missing)} โ€” `rmkit create --version {missing[-1]}` will be rejected", + ) + else: + report.info( + "version-mapping", + f"newest mapped rmk minor is {'.'.join(str(p) for p in floor[:2])}, " + "which is current with crates.io", + ) + + +# -------------------------------------------------------------------------- +# Axes +# -------------------------------------------------------------------------- + + +def build_rmkit() -> Path: + subprocess.run(["cargo", "build", "--quiet"], cwd=REPO_ROOT, check=True) + return REPO_ROOT / "target" / "debug" / "rmkit" + + +def run_main_axis(workdir: Path) -> Report: + report = Report("main") + tree = fetch_rmk_tree("main", "heads", workdir / "rmk-main") + upstream = read_upstream(tree, "rmk main") + + cargo_toml = (REPO_ROOT / "Cargo.toml").read_text() + keyboard_toml_rs = (REPO_ROOT / "src" / "keyboard_toml.rs").read_text() + chip_rs = (REPO_ROOT / "src" / "chip.rs").read_text() + + disabled, enabled = extract_feature_names(keyboard_toml_rs) + split_chips, unibody_chips = extract_chip_options(chip_rs) + + check_versions(report, extract_rmk_config_req(cargo_toml), upstream) + check_features(report, disabled, enabled, upstream) + check_config_schema(report, build_rmkit(), upstream) + check_templates(report, split_chips, unibody_chips, extract_board_chip_map(chip_rs)) + return report + + +def run_release_axis(workdir: Path) -> Report: + report = Report("release", gating=False) + + rmkit_version = crates_io_newest("rmkit") + rmk_version = crates_io_newest("rmk") + report.info("versions", f"rmkit {rmkit_version} (crates.io) vs rmk {rmk_version} (crates.io)") + + tag = resolve_rmk_tag(rmk_version) + tree = fetch_rmk_tree(tag, "tags", workdir / "rmk-release") + upstream = read_upstream(tree, f"rmk {rmk_version}") + + def rmkit_file(path: str) -> str: + return http_get( + f"https://raw.githubusercontent.com/{RMKIT_REPO}/v{rmkit_version}/{path}" + ).decode() + + cargo_toml = rmkit_file("Cargo.toml") + keyboard_toml_rs = rmkit_file("src/keyboard_toml.rs") + chip_rs = rmkit_file("src/chip.rs") + + disabled, enabled = extract_feature_names(keyboard_toml_rs) + split_chips, unibody_chips = extract_chip_options(chip_rs) + + check_versions(report, extract_rmk_config_req(cargo_toml), upstream) + check_features(report, disabled, enabled, upstream) + # No config-schema check here: it is subsumed by the version check. If the + # released rmkit and the released rmk resolve the same rmk-config, they + # cannot disagree about the schema. Only the main axis can have matching + # version numbers with differing content. + check_templates(report, split_chips, unibody_chips, extract_board_chip_map(chip_rs)) + return report + + +# -------------------------------------------------------------------------- +# Output +# -------------------------------------------------------------------------- + +ICONS = {"fail": "โœ—", "warn": "!", "info": "ยท"} + + +def reconcile(release: Report, main: Report) -> None: + """Annotate release-axis failures with whether main has already fixed them. + + A finding on both axes is a live bug. One that only shows on the release + axis is already fixed in the working tree and just needs a release โ€” very + different calls to action, so say which it is. + """ + live = {(f.check, f.message) for f in main.findings if f.level == "fail"} + for finding in release.findings: + if finding.level != "fail": + continue + if (finding.check, finding.message) in live: + finding.message += "\nโ†’ still broken on main; fix it before releasing" + else: + finding.message += "\nโ†’ already fixed on main; ships with the next release" + + +def print_report(report: Report) -> None: + scope = "" if report.gating else " (reports only, never gates)" + print(f"\n=== {report.axis} axis ==={scope}") + for finding in report.findings: + icon = ICONS[finding.level] if report.gating else ICONS.get(finding.level, "!").replace("โœ—", "!") + head, *rest = finding.message.split("\n") + print(f" {icon} [{finding.check}] {head}") + for line in rest: + print(f" {line}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--axis", choices=["main", "release", "both"], default="both") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + reports: list[Report] = [] + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + release = run_release_axis(workdir) if args.axis in ("release", "both") else None + main_ = run_main_axis(workdir) if args.axis in ("main", "both") else None + if release and main_: + reconcile(release, main_) + reports = [r for r in (release, main_) if r] + + shared = Report("shared") + check_version_mapping(shared) + reports.append(shared) + + gating_failures = [r.axis for r in reports if r.gating and r.failed] + + if args.format == "json": + print(json.dumps( + { + "drifted": bool(gating_failures), + "reports": [ + {"axis": r.axis, "gating": r.gating, "findings": [vars(f) for f in r.findings]} + for r in reports + ], + }, + indent=2, + )) + else: + for report in reports: + print_report(report) + print() + if gating_failures: + print(f"DRIFT DETECTED on: {', '.join(gating_failures)}") + elif any(r.failed for r in reports): + print("No drift on gating axes; see the release axis for what a release would fix.") + else: + print("No drift detected.") + + return 1 if gating_failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/version.rs b/src/version.rs index fbdf37f..88c9520 100644 --- a/src/version.rs +++ b/src/version.rs @@ -21,7 +21,7 @@ pub async fn resolve_template_version(version: Option<&str>) -> Result { if v == "latest" || v == "main" { - return Ok("main".to_string()) + return Ok("main".to_string()); } // User provided a version, validate it From cc96b107c3b8f5c0cf891f50fbb6cec1e9530a80 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Thu, 27 Aug 2026 01:13:13 +0800 Subject: [PATCH 5/6] chore: extend CI and the drift check to cover rynk-kle and the tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rmkit layout` added a second dependency on the rmk workspace, and with it a second way to drift: rynk-kle pins rmkit to a point in rmk's history just as rmk-config does. Track both โ€” the checker now takes a set of crates instead of hardcoding rmk-config, reports each one's shipped version, and says which capability breaks when one falls out of step. Two wrinkles the tracking has to handle: rynk's members inherit `version.workspace = true`, so resolve against the nearest ancestor `[workspace.package]`, and older rmk releases predate rynk entirely, so a missing crate is a finding rather than a crash. Also run `cargo test` in CI, which the layout work made worth doing, and apply rustfmt to the files the new fmt gate would otherwise reject. The one non-mechanical part is a blank line in tests/cli.rs: without it rustfmt reads the comment as a continuation of the preceding trailing comment and indents it off the right margin. Signed-off-by: Haobo Gu Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 3 + scripts/check_upstream_drift.py | 131 +++++++++++++++++++++++--------- src/main.rs | 4 +- tests/cli.rs | 39 ++++++++-- 4 files changed, 132 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e687a6..edfd46c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: build run: cargo build --locked + - name: test + run: cargo test --locked + - name: Smoke-test the bundled keyboard.toml run: | set -euo pipefail diff --git a/scripts/check_upstream_drift.py b/scripts/check_upstream_drift.py index 7b86402..f8d6e51 100644 --- a/scripts/check_upstream_drift.py +++ b/scripts/check_upstream_drift.py @@ -46,6 +46,15 @@ USER_AGENT = "rmkit-upstream-drift-check" +# Crates rmkit depends on that live in the rmk workspace, mapped to their +# manifest path inside an rmk tree. Each one pins rmkit to a point in rmk's +# history, so each one can drift. Add a crate here when rmkit starts depending +# on it. +TRACKED_DEPS = { + "rmk-config": "rmk-config/Cargo.toml", + "rynk-kle": "rynk/rynk-kle/Cargo.toml", +} + # Example keyboard.toml files that rmkit is not expected to parse. Add entries # here (with a reason) rather than weakening the check. EXPECTED_PARSE_FAILURES: dict[str, str] = {} @@ -147,16 +156,25 @@ def resolve_rmk_tag(version: str) -> str: # behaviour, a refactor should force a look at this file. -def extract_rmk_config_req(cargo_toml: str) -> str: - manifest = tomllib.loads(cargo_toml) - dep = manifest["dependencies"]["rmk-config"] - if isinstance(dep, str): - return dep - if "version" in dep: - return dep["version"] - if "git" in dep: - return f"git:{dep.get('rev', dep.get('branch', 'HEAD'))}" - raise RuntimeError(f"cannot read an rmk-config version requirement from {dep!r}") +def extract_dep_reqs(cargo_toml: str) -> dict[str, str]: + """Read rmkit's requirement on each tracked rmk-workspace crate.""" + dependencies = tomllib.loads(cargo_toml)["dependencies"] + reqs: dict[str, str] = {} + for name in TRACKED_DEPS: + dep = dependencies.get(name) + if dep is None: + continue # rmkit did not depend on this crate at this revision + if isinstance(dep, str): + reqs[name] = dep + elif "version" in dep: + reqs[name] = dep["version"] + elif "git" in dep: + reqs[name] = f"git:{dep.get('rev', dep.get('branch', 'HEAD'))}" + else: + raise RuntimeError(f"cannot read a version requirement for {name} from {dep!r}") + if not reqs: + raise RuntimeError(f"rmkit depends on none of {sorted(TRACKED_DEPS)}") + return reqs def extract_feature_names(keyboard_toml_rs: str) -> tuple[set[str], set[str]]: @@ -236,21 +254,46 @@ class Upstream: label: str rmk_version: str - rmk_config_version: str rmk_types_version: str + dep_versions: dict[str, str] features: dict[str, list[str]] example_configs: list[Path] +def crate_version(tree: Path, manifest_path: str) -> str | None: + """None when the crate does not exist in this tree โ€” older rmk releases + predate some of the workspace members rmkit now depends on.""" + manifest = tree / manifest_path + if not manifest.is_file(): + return None + version = tomllib.loads(manifest.read_text())["package"]["version"] + if isinstance(version, str): + return version + # `version.workspace = true` โ€” resolve against the nearest ancestor + # manifest that defines `[workspace.package]`. rynk's members do this. + for directory in manifest.parent.parents: + candidate = directory / "Cargo.toml" + if not candidate.is_file(): + continue + inherited = tomllib.loads(candidate.read_text()).get("workspace", {}).get("package", {}) + if "version" in inherited: + return inherited["version"] + if directory == tree: + break + raise RuntimeError(f"cannot resolve the workspace version for {manifest_path}") + + def read_upstream(tree: Path, label: str) -> Upstream: rmk_manifest = tomllib.loads((tree / "rmk" / "Cargo.toml").read_text()) - config_manifest = tomllib.loads((tree / "rmk-config" / "Cargo.toml").read_text()) - types_manifest = tomllib.loads((tree / "rmk-types" / "Cargo.toml").read_text()) return Upstream( label=label, rmk_version=rmk_manifest["package"]["version"], - rmk_config_version=config_manifest["package"]["version"], - rmk_types_version=types_manifest["package"]["version"], + rmk_types_version=crate_version(tree, "rmk-types/Cargo.toml"), + dep_versions={ + name: version + for name, path in TRACKED_DEPS.items() + if (version := crate_version(tree, path)) is not None + }, features=rmk_manifest.get("features", {}), # `use_config` only: those are the full keyboard.toml files rmkit is # built to consume. The `use_rust` examples carry partial configs with @@ -265,29 +308,45 @@ def read_upstream(tree: Path, label: str) -> Upstream: # -------------------------------------------------------------------------- -def check_versions(report: Report, rmk_config_req: str, upstream: Upstream) -> None: +DRIFT_CONSEQUENCE = { + "rmk-config": "rmkit parses a different keyboard.toml schema than the projects it generates", + "rynk-kle": "`rmkit layout` converts against a different layout format than rmk understands", +} + + +def check_versions(report: Report, reqs: dict[str, str], upstream: Upstream) -> None: + shipped = " ยท ".join(f"{name} {version}" for name, version in upstream.dep_versions.items()) report.info( "versions", - f"rmk {upstream.rmk_version} ยท rmk-config {upstream.rmk_config_version} " - f"ยท rmk-types {upstream.rmk_types_version} ({upstream.label})", + f"rmk {upstream.rmk_version} ยท rmk-types {upstream.rmk_types_version} " + f"ยท {shipped} ({upstream.label})", + ) + report.info( + "versions", + "rmkit requires " + ", ".join(f"{name} {req}" for name, req in reqs.items()), ) - report.info("versions", f"rmkit requires rmk-config {rmk_config_req}") - - if rmk_config_req.startswith("git:"): - report.warn( - "versions", - f"rmkit depends on rmk-config via git ({rmk_config_req[4:]}); it cannot be " - "published in this state", - ) - return - if not req_admits(rmk_config_req, upstream.rmk_config_version): - report.fail( - "versions", - f"rmkit requires rmk-config {rmk_config_req}, but {upstream.label} ships " - f"rmk-config {upstream.rmk_config_version} โ€” rmkit parses a different " - "keyboard.toml schema than the projects it generates", - ) + for name, req in reqs.items(): + if req.startswith("git:"): + report.warn( + "versions", + f"rmkit depends on {name} via git ({req[4:]}); it cannot be published " + "in this state", + ) + continue + shipped_version = upstream.dep_versions.get(name) + if shipped_version is None: + report.fail( + "versions", + f"rmkit requires {name}, but {upstream.label} has no such crate", + ) + continue + if not req_admits(req, shipped_version): + report.fail( + "versions", + f"rmkit requires {name} {req}, but {upstream.label} ships {name} " + f"{shipped_version} โ€” {DRIFT_CONSEQUENCE.get(name, 'rmkit is out of step')}", + ) def check_features(report: Report, disabled: set[str], enabled: set[str], upstream: Upstream) -> None: @@ -433,7 +492,7 @@ def run_main_axis(workdir: Path) -> Report: disabled, enabled = extract_feature_names(keyboard_toml_rs) split_chips, unibody_chips = extract_chip_options(chip_rs) - check_versions(report, extract_rmk_config_req(cargo_toml), upstream) + check_versions(report, extract_dep_reqs(cargo_toml), upstream) check_features(report, disabled, enabled, upstream) check_config_schema(report, build_rmkit(), upstream) check_templates(report, split_chips, unibody_chips, extract_board_chip_map(chip_rs)) @@ -463,7 +522,7 @@ def rmkit_file(path: str) -> str: disabled, enabled = extract_feature_names(keyboard_toml_rs) split_chips, unibody_chips = extract_chip_options(chip_rs) - check_versions(report, extract_rmk_config_req(cargo_toml), upstream) + check_versions(report, extract_dep_reqs(cargo_toml), upstream) check_features(report, disabled, enabled, upstream) # No config-schema check here: it is subsumed by the version check. If the # released rmkit and the released rmk resolve the same rmk-config, they diff --git a/src/main.rs b/src/main.rs index 80a7cf7..a22e9f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,9 @@ async fn main() -> Result<(), Box> { to_vial, no_validate, } => layout_cmd::convert(&input, output.as_deref(), to_vial, !no_validate), - args::LayoutCommands::Show { input, variant } => layout_cmd::show(&input, variant.as_deref()), + args::LayoutCommands::Show { input, variant } => { + layout_cmd::show(&input, variant.as_deref()) + } }; // The layout tools speak plain stderr + exit code (their output is // piped/captured), not the interactive error style of create/init. diff --git a/tests/cli.rs b/tests/cli.rs index fc3843c..cb8ba36 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -30,6 +30,7 @@ fn ansi60_split_backspace_converts_and_validates() { assert!(stdout.contains("(1,0,@1.5u)")); // 1.5u tab assert!(stdout.contains("(2,0,@1.75u)")); // 1.75u caps assert!(stdout.contains("(4,3,@6.25u)")); // 6.25u space + // Caps are all stock widths; the only generated shape is the 1u reset the // split-backspace variant uses to shrink (0,13). assert!(stdout.contains("s1 = { w = 1.0 }")); @@ -114,8 +115,9 @@ fn converted_toml_fixtures_are_up_to_date() { continue; } let golden = json.with_extension("toml"); - let expected = std::fs::read_to_string(&golden) - .unwrap_or_else(|_| panic!("missing golden {golden:?} โ€” regenerate (see comment above)")); + let expected = std::fs::read_to_string(&golden).unwrap_or_else(|_| { + panic!("missing golden {golden:?} โ€” regenerate (see comment above)") + }); let name = json.file_name().unwrap().to_string_lossy(); let out = Command::new(env!("CARGO_BIN_EXE_rmkit")) .current_dir(env!("CARGO_MANIFEST_DIR")) @@ -123,12 +125,22 @@ fn converted_toml_fixtures_are_up_to_date() { .arg(format!("tests/fixtures/{name}")) .output() .expect("failed to run rmkit"); - assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!(stdout, expected, "stale golden for {name} โ€” regenerate (see comment above)"); + assert_eq!( + stdout, expected, + "stale golden for {name} โ€” regenerate (see comment above)" + ); checked += 1; } - assert!(checked >= 5, "expected the committed fixture pairs, found {checked}"); + assert!( + checked >= 5, + "expected the committed fixture pairs, found {checked}" + ); } #[test] @@ -145,15 +157,26 @@ fn layout_show_accepts_vial_and_kle_json() { // A vial.json renders directly, without converting to keyboard.toml first. let out = show("corne.json", &[]); let stdout = String::from_utf8_lossy(&out.stdout); - assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); assert!(stdout.contains("42 keys"), "stdout:\n{stdout}"); assert!(stdout.contains("โ”‚ 0,0 โ”‚"), "stdout:\n{stdout}"); // VIA layout options become variants, so --variant works on a vial.json. let out = show("ansi60_splitbs.json", &["--variant", "Split_Backspace"]); let stdout = String::from_utf8_lossy(&out.stdout); - assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); - assert!(stdout.contains("variant 'Split_Backspace'"), "stdout:\n{stdout}"); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + stdout.contains("variant 'Split_Backspace'"), + "stdout:\n{stdout}" + ); // A raw KLE export renders too, with the row-major fallback warning. let out = show("kle_export.json", &[]); From 66163bf93591cd99f993e61434e04a7fc40f3cc2 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 29 Aug 2026 14:22:17 +0800 Subject: [PATCH 6/6] chore: track the rmk 0.9.0 release deps, re-add nrf52833 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rmk-config 0.8.0 / rynk-kle 0.3.0 are what the published RMK 0.9.0 ships; rynk-kle 0.3.0 pins rmk-config =0.8.0, so the two move together. Re-add nrf52833 to the chip options now that rmk-template's 0.9 update carries its template โ€” publish rmkit only after that PR merges. --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 7 ++++--- src/chip.rs | 1 + 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c7a51b6..f152dfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1753,9 +1753,9 @@ dependencies = [ [[package]] name = "rmk-config" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf80b83061584d792b01687f3f1b002bbf65f158db5833316471789265caac6" +checksum = "cd144f2407222f7790a3f9386758625d5e589e8fc5f9d850e9383f9773a55eb3" dependencies = [ "config", "miniz_oxide 0.9.1", @@ -1771,9 +1771,9 @@ dependencies = [ [[package]] name = "rmk-types" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7172f033549955a92fa2cb35f6c16ee45cfd77eacc3e251fe44137305851cd83" +checksum = "e99d79529fd045848d7692504eb733fb4dd7407f2ba1cf11397508cc68125140" dependencies = [ "bitfield-struct", "cobs", @@ -1880,9 +1880,9 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rynk" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "725415914d7f93aeeb878eae096b6bdafb590b3e8e6c70ebdb54874733c2d274" +checksum = "f1cabd1d713df36886cce6c14d0f472156d17a324f9f635c5d64a562f015cd26" dependencies = [ "critical-section", "embassy-futures", @@ -1899,9 +1899,9 @@ dependencies = [ [[package]] name = "rynk-kle" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8f1d72056be61f44a372336360cde3f77fb815d4c6860f9ec3199697138e521" +checksum = "e23357b64d8879311b7289b2f844e07a2ec34a2c6a3ece9002e1d9213e126fb8" dependencies = [ "kle-serial", "rmk-config", diff --git a/Cargo.toml b/Cargo.toml index 21c0dd1..e77def1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,12 +9,13 @@ description = "rmkit is a toolkit set for RMK keyboard firmware" license = "Apache-2.0" [dependencies] -# Pins the `keyboard.toml` schema rmkit understands: 0.7.0 is the config crate +# Pins the `keyboard.toml` schema rmkit understands: 0.8.0 is the config crate # RMK 0.9.0 ships. Bump it in lockstep with each RMK release, since the structs # are `deny_unknown_fields` and reject keys added by later versions. -rmk-config = "0.7.0" +rmk-config = "0.8.0" # The KLE/Vial โ†” [layout] conversion engine; `rmkit layout` is CLI glue over it. -rynk-kle = "0.2.0" +# Its 0.3.0 pins rmk-config =0.8.0, so the two must move together. +rynk-kle = "0.3.0" clap = { version = "4.5.23", features = ["derive", "string"] } toml = "0.9.8" serde = "1.0" diff --git a/src/chip.rs b/src/chip.rs index 570b3e3..55526b5 100644 --- a/src/chip.rs +++ b/src/chip.rs @@ -36,6 +36,7 @@ pub(crate) fn get_chip_options(split: bool) -> Vec<&'static str> { "rp2040", "Pi Pico W", "nrf52832", + "nrf52833", "esp32c3", "esp32s3", "esp32c6",