ci: guard the e-ink identity whitelist against Rust/Kotlin drift (#6834)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Publish Docker image / merge (push) Blocked by required conditions
Build with Nix / tests (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / nix_flake_check (push) Waiting to run
PR checks / rust_lint (push) Waiting to run
PR checks / koplugin_rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run

* ci: guard the e-ink identity whitelist against Rust/Kotlin drift

The e-ink device whitelist lives twice: as the Rust source of truth in
eink_identity.rs (host app's e-ink device paths) and as the Kotlin port
in the tauri-plugin-webview-upgrade submodule (the plugin's night-mode
pin, readest/tauri-plugin-webview-upgrade#4). Both must classify devices
identically, but until now nothing stopped one side from changing
without the other - especially now that they sit in different repos.

Adds a zero-dependency Node check that extracts the three lists
(EINK_MANUFACTURERS / EINK_MODELS / EINK_AMBIGUOUS_MODELS) from both
files and fails with the offending entries whenever they drift. Wired
into the rust_lint job (whose checkout already includes submodules and
whose src-tauri paths-filter fires for both the Rust file and the plugin
submodule), plus a `check:eink-identity` package script for local runs.

* ci: anchor the Rust whitelist extraction to line starts

Same class of fix as the Kotlin extractor: a bracket inside an entry
comment (e.g. // e-ink [A5] readers) truncated the non-greedy capture at
the comment instead of the list end, producing false drift. Multi-line
lists anchor the closing bracket to a line start; single-line lists use
a bracket-free capture.

* ci: address review findings on the identity sync guard

- Run the sync check unconditionally: a PR touching only
  scripts/check-eink-identity-sync.mjs did not match the tauri
  paths-filter, so a checker regression could merge without the check
  running. The check is ~0.1s.
- Strip // line comments before extracting quoted entries: a
  commented-out entry must not count as active on one side while still
  active on the other, or real drift is missed.
- Anchor multi-line list openings to a newline (Rust `&[\n`, Kotlin
  `listOf(\n`): a multi-line match could previously start at a completed
  single-line declaration and capture through an unrelated later list.
- Make all regexes CR/LF tolerant (`\r?\n`): a checkout with CRLF line
  endings made every list extraction fail outright.

* docs(ci): add JSDoc to the identity sync guard functions
This commit is contained in:
k6G52m4Dz75W 2026-10-15 22:24:52 +08:00 • committed by GitHub
parent 46c686eee3
commit 61ef15e005
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 87 additions and 0 deletions

View file

@ -56,6 +56,11 @@ jobs:
filters: |
tauri:
- 'apps/readest-app/src-tauri/**'
# Unconditional: the check must also run when its own source
# (scripts/check-eink-identity-sync.mjs) changes, which the tauri
# paths-filter above does not match.
- name: e-ink identity whitelist sync (Rust vs Kotlin)
run: node scripts/check-eink-identity-sync.mjs
- name: setup sccache
if: steps.changes.outputs.tauri == 'true'
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11

View file

@ -7,6 +7,7 @@
"test:lua": "pnpm --filter @readest/readest-app test:lua",
"lint": "pnpm --filter @readest/readest-app lint",
"lint:lua": "pnpm --filter @readest/readest-app lint:lua",
"check:eink-identity": "node scripts/check-eink-identity-sync.mjs",
"tauri": "pnpm --filter @readest/readest-app tauri",
"dev-web": "pnpm --filter @readest/readest-app dev-web",
"prepare": "husky",

View file

@ -0,0 +1,81 @@
// Guards the e-ink device identity whitelist against cross-repo drift.
//
// The whitelist lives twice: as the Rust source of truth in
// src-tauri/src/eink_identity.rs (driving the host app's e-ink device
// paths) and as the Kotlin port inside the tauri-plugin-webview-upgrade
// submodule (driving the plugin's night-mode pin, readest/
// tauri-plugin-webview-upgrade#4). The two must classify devices
// identically; this script fails CI whenever one side changes without the
// other.
import { readFileSync } from 'node:fs';
const RUST_PATH = 'apps/readest-app/src-tauri/src/eink_identity.rs';
const KOTLIN_PATH =
'apps/readest-app/src-tauri/plugins/tauri-plugin-webview-upgrade/android/src/main/java/com/readest/webview_upgrade/EinkIdentity.kt';
const LISTS = ['EINK_MANUFACTURERS', 'EINK_MODELS', 'EINK_AMBIGUOUS_MODELS'];
/** Extract the quoted, non-empty entries from a captured list block. */
function quotedStrings(block) {
// Strip // line comments first: a commented-out entry ("onyx",) must not
// count as active on one side while still active on the other.
const active = block.replace(/\/\/.*$/gm, '');
return [...active.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
}
/** Extract one `NAME: &[&str] = &[...]` whitelist from the Rust source. */
function extractRustList(source, name) {
// Two layouts: the multi-line form (opening delimiter followed by a
// newline; entry comments may contain brackets, so the closing bracket is
// anchored to a line start) and the one-line form `&["a9", "nova"]`.
// Anchoring the opening delimiter keeps a multi-line match from starting
// at a completed single-line declaration.
const multi = source.match(
new RegExp(`${name}\\s*:\\s*&\\[&str\\]\\s*=\\s*&\\[\\r?\\n([\\s\\S]*?)\\r?\\n\\]`),
);
const single =
multi ? null : source.match(new RegExp(`${name}\\s*:\\s*&\\[&str\\]\\s*=\\s*&\\[([^\\]\\n]*)\\]`));
const match = multi ?? single;
if (!match) throw new Error(`list "${name}" not found in ${RUST_PATH}`);
return quotedStrings(match[1]);
}
/** Extract one `NAME = listOf(...)` whitelist from the Kotlin port. */
function extractKotlinList(source, name) {
// Same anchoring as the Rust extractor, with parens instead of brackets.
const multi = source.match(new RegExp(`\\b${name}\\s*=\\s*listOf\\(\\r?\\n([\\s\\S]*?)\\r?\\n\\)`));
const single = multi ? null : source.match(new RegExp(`\\b${name}\\s*=\\s*listOf\\(([^)\\n]*)\\)`));
const match = multi ?? single;
if (!match) throw new Error(`list "${name}" not found in ${KOTLIN_PATH}`);
return quotedStrings(match[1]);
}
/** Entries present on only one side, labeled `rust-only` / `kotlin-only`. */
function drift(rust, kotlin) {
return [
...rust.filter((entry) => !kotlin.includes(entry)).map((entry) => `rust-only: "${entry}"`),
...kotlin.filter((entry) => !rust.includes(entry)).map((entry) => `kotlin-only: "${entry}"`),
];
}
let failed = false;
const rust = readFileSync(RUST_PATH, 'utf8');
const kotlin = readFileSync(KOTLIN_PATH, 'utf8');
for (const name of LISTS) {
const rustList = extractRustList(rust, name);
const kotlinList = extractKotlinList(kotlin, name);
const differences = drift(rustList, kotlinList);
if (differences.length > 0) {
failed = true;
console.error(`${name} drifted between Rust and Kotlin:`);
for (const difference of differences) console.error(` ${difference}`);
} else {
console.log(`${name}: ${rustList.length} entries in sync`);
}
}
if (failed) {
console.error(`\nSync ${KOTLIN_PATH} with ${RUST_PATH} (or open the PRs in both repos).`);
process.exit(1);
}