Files
tauri-plugins-workspace/.scripts/ci/affected-rust-packages.js
T
Lucas Fernandes Nogueira 9a74a78c71 ci: only run what a change affects, consolidate jobs (#3674)
* ci: only run what a change affects, consolidate jobs

- detect affected plugins from changed files and Cargo.lock dependency trees
- test/lint Rust per platform in shards instead of one job per plugin
- fold check-generated-files into lint-javascript, fmt into a single job
- skip fat LTO in integration test builds, single ABI in test-android

* ci: run e2e on bumps, use published tauri CLI, PR-safe pnpm cache

- trigger e2e on Cargo/pnpm lockfile and manifest changes
- install the latest 2.x tauri-cli with cargo-binstall in integration tests
- add setup-pnpm action: restore the store everywhere, save only on v2/v3

* fix clippy, ios build

* fix bumps [skip ci]
2026-10-09 12:57:12 -03:00

255 lines
8.1 KiB
JavaScript

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// Lists the plugin crates a change affects, so the Rust workflows only build and test those.
//
// A plugin is affected when:
// - a file in its directory changed (except the JS / docs / Android files `cargo` never reads),
// - a plugin it depends on is affected (e.g. `fs` for `dialog`), or
// - a `Cargo.lock` entry in its dependency tree changed (so a dependency bump only tests the
// plugins that actually use that dependency).
// Every plugin is affected when a workspace-wide file (root `Cargo.toml`, toolchain, cargo
// config, this script, the files in `GLOBAL_PATHS`) changed, or when there is nothing to diff
// against.
//
// Environment:
// - BASE: the revision to diff `HEAD` against; empty or all zeros means every plugin.
// - ALL: `true` to select every plugin.
// - GLOBAL_PATHS: newline separated paths (usually the calling workflow) that affect every plugin.
// - SHARD_SIZE: the maximum number of plugins per shard (default 8).
//
// Writes `packages` (JSON array) and `shards` (JSON array of `{ name, packages, save }`, where
// `packages` is space separated and `save` is true for the shard that should save the cache)
// to `$GITHUB_OUTPUT`.
import { execFileSync } from 'child_process'
import fs from 'fs'
import path from 'path'
// Crates whose dependency trees cover most of the other plugins' dependencies. They go first so
// the shard that saves the cache (the first one) restores as much as possible for the others.
const preferredFirst = [
'tauri-plugin-upload',
'tauri-plugin-sql',
'tauri-plugin-updater',
'tauri-plugin-single-instance'
]
const globalPaths = [
'Cargo.toml',
'rust-toolchain',
'rust-toolchain.toml',
'.cargo/',
'.scripts/ci/affected-rust-packages.js',
...(process.env.GLOBAL_PATHS ?? '')
.split('\n')
.map((p) => p.trim())
.filter(Boolean)
]
// Paths inside a plugin directory that never reach `cargo`.
const ignoredInPlugin = [
/^guest-js\//,
/^dist-js\//,
/^node_modules\//,
/^android\//,
/(^|\/)[^/]+\.md$/,
/^package\.json$/,
/^tsconfig\.json$/,
/^rollup\.config\.js$/,
/^api-iife\.js$/,
/^banner\.png$/,
/^LICENSE/,
/^contributors\//
]
function git(...args) {
return execFileSync('git', args, {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe']
})
}
function readPlugins() {
const plugins = new Map() // directory -> crate name
for (const dir of fs.readdirSync('plugins')) {
const manifest = path.join('plugins', dir, 'Cargo.toml')
if (!fs.existsSync(manifest)) continue
const name = fs
.readFileSync(manifest, 'utf8')
.match(/^\[package\][^[]*?^name\s*=\s*"([^"]+)"/m)?.[1]
if (name) plugins.set(dir, name)
}
return plugins
}
// Parses the `[[package]]` entries of a Cargo.lock into a map of
// `name version (source)` -> `{ name, version, source, checksum, dependencies, fingerprint }`.
function parseLock(text) {
const packages = new Map()
for (const block of text.split(/^\[\[package\]\]$/m).slice(1)) {
const field = (key) =>
block.match(new RegExp(`^${key} = "([^"]*)"`, 'm'))?.[1]
const name = field('name')
const version = field('version')
const source = field('source')
const dependencies = [
...(block.match(/^dependencies = \[([^\]]*)\]/m)?.[1] ?? '').matchAll(
/"([^"]+)"/g
)
].map((m) => m[1])
const id = source ? `${name} ${version} (${source})` : `${name} ${version}`
const checksum = field('checksum')
packages.set(id, { name, version, source, checksum, dependencies })
}
const byName = new Map()
for (const [id, pkg] of packages) {
byName.set(pkg.name, [...(byName.get(pkg.name) ?? []), id])
}
// dependency entries are `name`, `name version` or `name version (source)`
const resolve = (dep) => {
if (packages.has(dep)) return dep
const [name, version] = dep.split(' ')
const candidates = byName.get(name) ?? []
return version
? candidates.find((id) => packages.get(id).version === version)
: candidates[0]
}
for (const pkg of packages.values()) {
pkg.dependencies = pkg.dependencies.map(resolve).filter(Boolean).sort()
// what identifies the resolved entry: dependencies are compared by what they resolve to, as
// cargo spells a dependency differently (`name` or `name version`) depending on how many
// versions of it the lockfile holds
pkg.fingerprint = JSON.stringify([pkg.checksum, pkg.dependencies])
}
return packages
}
function affectedPackages(plugins) {
const all = [...plugins.values()]
const base = process.env.BASE?.trim()
if (process.env.ALL === 'true') {
console.log('every plugin selected')
return all
}
if (!base || /^0+$/.test(base)) {
console.log('nothing to diff against, selecting every plugin')
return all
}
let changedFiles
try {
try {
git('cat-file', '-e', `${base}^{commit}`)
} catch {
git('fetch', '--no-tags', '--depth=1', 'origin', base)
}
changedFiles = git('diff', '--name-only', base, 'HEAD')
.split('\n')
.filter(Boolean)
} catch (error) {
console.log(
`could not diff against ${base}, selecting every plugin: ${error.stderr ?? error}`
)
return all
}
console.log(`changed files:\n ${changedFiles.join('\n ')}`)
const global = changedFiles.find((file) =>
globalPaths.some((p) => (p.endsWith('/') ? file.startsWith(p) : file === p))
)
if (global) {
console.log(`${global} affects every plugin`)
return all
}
const lock = parseLock(fs.readFileSync('Cargo.lock', 'utf8'))
const seeds = new Set()
for (const file of changedFiles) {
const [, dir, rest] = file.match(/^plugins\/([^/]+)\/(.+)$/) ?? []
const name = plugins.get(dir)
if (!name || ignoredInPlugin.some((pattern) => pattern.test(rest))) continue
for (const [id, pkg] of lock) {
if (pkg.name === name && !pkg.source) seeds.add(id)
}
}
if (changedFiles.includes('Cargo.lock')) {
let baseLock
try {
baseLock = parseLock(git('show', `${base}:Cargo.lock`))
} catch {
console.log('no Cargo.lock to compare with, selecting every plugin')
return all
}
for (const [id, pkg] of lock) {
if (baseLock.get(id)?.fingerprint !== pkg.fingerprint) seeds.add(id)
}
}
if (seeds.size) console.log(`changed crates:\n ${[...seeds].join('\n ')}`)
// a plugin is affected when its dependency tree (itself included) reaches a seed,
// so walk the reverse dependency graph from the seeds
const dependents = new Map()
for (const [id, pkg] of lock) {
for (const dep of pkg.dependencies) {
dependents.set(dep, [...(dependents.get(dep) ?? []), id])
}
}
const reached = new Set(seeds)
const queue = [...seeds]
while (queue.length) {
for (const dependent of dependents.get(queue.pop()) ?? []) {
if (!reached.has(dependent)) {
reached.add(dependent)
queue.push(dependent)
}
}
}
const reachedWorkspaceCrates = new Set(
[...reached]
.map((id) => lock.get(id))
.filter((pkg) => !pkg.source)
.map((pkg) => pkg.name)
)
return all.filter((name) => reachedWorkspaceCrates.has(name))
}
const plugins = readPlugins()
const packages = affectedPackages(plugins).sort((a, b) => {
const rank = (p) =>
preferredFirst.includes(p) ? preferredFirst.indexOf(p) : Infinity
return rank(a) - rank(b) || a.localeCompare(b)
})
const shardSize = Number(process.env.SHARD_SIZE) || 8
const shardCount = Math.ceil(packages.length / shardSize)
const perShard = Math.ceil(packages.length / Math.max(shardCount, 1))
const shards = Array.from({ length: shardCount }, (_, i) => {
const shard = packages.slice(i * perShard, (i + 1) * perShard)
return {
name:
shard.length <= 3
? shard.map((p) => p.replace('tauri-plugin-', '')).join(', ')
: `${i + 1}/${shardCount}`,
packages: shard.join(' '),
save: i === 0
}
})
console.log(`affected packages:\n ${packages.join('\n ') || '(none)'}`)
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
`packages=${JSON.stringify(packages)}\nshards=${JSON.stringify(shards)}\n`
)
}