From b92d56c98e352854f1279e38d0ed7f49dcb8f220 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:56:30 +0800 Subject: [PATCH] chore(contract): check generated bindings before push --- .github/workflows/frontend-check.yml | 22 ++------ .husky/pre-push | 1 + knip.json | 2 +- scripts/check-contract-changes.mjs | 69 ++++++++++++++++++++++++++ scripts/contract-inputs.json | 23 +++++++++ scripts/gen-contract.mjs | 69 +++++++++++++++++++------- src-tauri/src/bin/frontend-contract.rs | 13 +++-- 7 files changed, 158 insertions(+), 41 deletions(-) create mode 100644 scripts/check-contract-changes.mjs create mode 100644 scripts/contract-inputs.json diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 46f02f2fe..937f9c89b 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: read env: HUSKY: 0 @@ -99,20 +100,7 @@ jobs: id: check_inputs uses: dorny/paths-filter@v4 with: - filters: | - contract: - - 'src/services/contract.ts' - - 'src/services/bindings.ts' - - 'Cargo.lock' - - 'Cargo.toml' - - 'src-tauri/Cargo.toml' - - 'crates/**' - - 'src-tauri/src/**' - - 'scripts/gen-contract.mjs' - - 'src-tauri/src/core/notify/**' - - 'src-tauri/src/bin/**' - - 'src-tauri/src/lib.rs' - - '.github/workflows/frontend-check.yml' + filters: scripts/contract-inputs.json - name: Skip if contract inputs unchanged if: steps.check_inputs.outputs.contract != 'true' @@ -165,8 +153,6 @@ jobs: touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu" done - - name: Regenerate contract and require empty diff + - name: Check generated contract without changing the checkout if: steps.check_inputs.outputs.contract == 'true' - run: | - pnpm gen:contract - git diff --exit-code -- src/services/bindings.ts + run: pnpm gen:contract --check diff --git a/.husky/pre-push b/.husky/pre-push index 9396b29ff..c3b725ee9 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -5,4 +5,5 @@ if ! command -v "cargo-make" >/dev/null 2>&1; then cargo install --force cargo-make fi +node scripts/check-contract-changes.mjs --push "$1" || exit $? cargo make pre-push diff --git a/knip.json b/knip.json index 8f772135c..6ea552114 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/polyfills/*.js!"], + "entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"], "project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"], "include": [ "files", diff --git a/scripts/check-contract-changes.mjs b/scripts/check-contract-changes.mjs new file mode 100644 index 000000000..20c86d9f3 --- /dev/null +++ b/scripts/check-contract-changes.mjs @@ -0,0 +1,69 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dirname, '..') +const git = (...args) => + execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim() +const { contract } = JSON.parse( + fs.readFileSync(path.join(ROOT, 'scripts/contract-inputs.json'), 'utf8'), +) +const inputs = contract.map((pattern) => `:(glob)${pattern}`) +const args = process.argv.slice(2) +if (args.length !== 2 || args[0] !== '--push') { + console.error('usage: check-contract-changes.mjs --push ') + process.exit(2) +} +const hasChanges = (...range) => + git('diff', '--name-only', ...range, '--', ...inputs) !== '' +const assertCleanInputs = (...range) => { + if ( + hasChanges(...range) || + git('ls-files', '--others', '--exclude-standard', '--', ...inputs) + ) { + throw new Error( + 'Contract inputs differ from the pushed commit. Commit those changes before pushing.', + ) + } +} +let relevant = false +const head = git('rev-parse', 'HEAD') +for (const line of fs + .readFileSync(0, 'utf8') + .trim() + .split('\n') + .filter(Boolean)) { + const fields = line.trim().split(/\s+/) + if ( + fields.length !== 4 || + ![fields[1], fields[3]].every((sha) => /^[a-f0-9]{40,64}$/.test(sha)) + ) { + throw new Error('Invalid pre-push ref update') + } + const [, local, , remote] = fields + if (/^0+$/.test(local) || local === remote) continue + // A new remote branch has no upstream; use its fork point with the remote default branch. + const base = /^0+$/.test(remote) + ? git('merge-base', local, `refs/remotes/${args[1]}/HEAD`) + : remote + if (!hasChanges(base, local)) continue + const commit = git('rev-parse', `${local}^{commit}`) + if (commit !== head) + throw new Error( + 'Check out the pushed commit before validating its changed IPC contract.', + ) + assertCleanInputs('HEAD') + relevant = true +} +if (relevant) { + execFileSync( + process.execPath, + [path.join(ROOT, 'scripts/gen-contract.mjs'), '--check'], + { + cwd: ROOT, + stdio: 'inherit', + }, + ) +} else { + console.log('[contract] inputs unchanged; skipping Rust generation') +} diff --git a/scripts/contract-inputs.json b/scripts/contract-inputs.json new file mode 100644 index 000000000..4e9f93100 --- /dev/null +++ b/scripts/contract-inputs.json @@ -0,0 +1,23 @@ +{ + "contract": [ + "src-tauri/**/*.rs", + "crates/**/*.rs", + "Cargo.toml", + "Cargo.lock", + "src-tauri/Cargo.toml", + "crates/**/Cargo.toml", + "rust-toolchain.toml", + "src-tauri/tauri.conf*.json", + "src/services/bindings.ts", + "scripts/gen-contract.mjs", + "scripts/check-contract-changes.mjs", + "biome.json", + "biome.jsonc", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "scripts/contract-inputs.json", + ".github/workflows/frontend-check.yml", + ".husky/pre-push" + ] +} diff --git a/scripts/gen-contract.mjs b/scripts/gen-contract.mjs index b05b6562e..f1103d898 100644 --- a/scripts/gen-contract.mjs +++ b/scripts/gen-contract.mjs @@ -1,22 +1,55 @@ import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' const ROOT = path.resolve(import.meta.dirname, '..') -execFileSync( - 'cargo', - ['run', '-p', 'clash-verge', '--bin', 'frontend-contract', '--locked', '-q'], - { - cwd: ROOT, - stdio: 'inherit', - }, -) -execFileSync( - process.execPath, - [ - path.join(ROOT, 'node_modules/@biomejs/biome/bin/biome'), - 'format', - '--write', - 'src/services/bindings.ts', - ], - { cwd: ROOT, stdio: 'inherit' }, -) +const BINDINGS = path.join(ROOT, 'src/services/bindings.ts') +const args = process.argv.slice(2) +if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) { + console.error('usage: gen-contract.mjs [--check]') + process.exit(2) +} +const check = args[0] === '--check' +const temporary = check + ? fs.mkdtempSync(path.join(os.tmpdir(), 'verge-contract-')) + : null +const output = temporary ? path.join(temporary, 'bindings.ts') : BINDINGS +try { + execFileSync( + 'cargo', + [ + 'run', + '-p', + 'clash-verge', + '--bin', + 'frontend-contract', + '--locked', + '-q', + ...(check ? ['--', output] : []), + ], + { cwd: ROOT, stdio: 'inherit' }, + ) + const formatted = execFileSync( + process.execPath, + [ + path.join(ROOT, 'node_modules/@biomejs/biome/bin/biome'), + 'format', + '--stdin-file-path', + BINDINGS, + ], + { cwd: ROOT, input: fs.readFileSync(output) }, + ) + if (check) { + if (!formatted.equals(fs.readFileSync(BINDINGS))) { + console.error( + 'IPC bindings are out of date. Run pnpm gen:contract and commit the generated file.', + ) + process.exitCode = 1 + } + } else { + fs.writeFileSync(BINDINGS, formatted) + } +} finally { + if (temporary) fs.rmSync(temporary, { recursive: true, force: true }) +} diff --git a/src-tauri/src/bin/frontend-contract.rs b/src-tauri/src/bin/frontend-contract.rs index aa9b445d8..ba205060d 100644 --- a/src-tauri/src/bin/frontend-contract.rs +++ b/src-tauri/src/bin/frontend-contract.rs @@ -1,7 +1,12 @@ fn main() -> Result<(), Box> { - app_lib::ipc::builder().export( - specta_typescript::Typescript::default(), - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts"), - )?; + let mut args = std::env::args_os().skip(1); + let output = args + .next() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts")); + if args.next().is_some() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "expected at most one output path").into()); + } + app_lib::ipc::builder().export(specta_typescript::Typescript::default(), output)?; Ok(()) }