From b1ab8c8e4babf8e94a5c9d2e19ea9d978bac9005 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 14 Oct 2026 18:12:16 +1100 Subject: [PATCH 1/2] ci(dependabot): keep the EQL entry out of the root workspace's manifests The EQL workspace depends by path on root-workspace crates: tests/encryption on stack-kms and stack-encrypt, eql-bindings on stack-encrypt. Those crates declare recipher, cllw-ore and vitaminc as `{ workspace = true }`, so their requirements come from the ROOT Cargo.toml even inside the EQL workspace. The /packages/eql Dependabot entry ignored none of them, so its monthly PR (#1205) bumped them in the EQL lock and, to satisfy the root's exact pins, rewrote the root Cargo.toml without updating the root Cargo.lock or the Go guest's recipher pin. Every root `--locked` build and the guest failed. The entry is now `versioning-strategy: lockfile-only`, so it cannot edit any manifest, and it ignores the crates the root entry moves by hand (recipher, cllw-ore, zerokms-protocol, vitaminc*), so its lockfile does not move them apart from the root's. scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs fails when the entry stops being lockfile-only, when the root entry ignores a crate it does not, or on a stale exemption. It derives the path dependencies from the tracked EQL manifests, and fails if they disappear rather than passing vacuously. Closes #1207 --- .github/dependabot.yml | 34 +++- .../dependabot-eql-inherited-ignores.test.mjs | 174 ++++++++++++++++++ 2 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c64213fce..66e82f7ee 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -138,9 +138,9 @@ updates: - version-update:semver-major # ── Cargo (packages/eql — the second Rust workspace) ─────────── - # The EQL subtree brought a second Cargo workspace: five members - # (crates/eql-{domains,codegen,tests-macros,bindings} plus tests/sqlx) and - # its own Cargo.lock. It needs its own entry because Dependabot's cargo + # The EQL subtree brought a second Cargo workspace: six members + # (crates/eql-{domains,codegen,tests-macros,bindings}, tests/sqlx and + # tests/encryption) and its own Cargo.lock. It needs its own entry because Dependabot's cargo # `directory:` is a single workspace root, not a glob — the protect-ffi entry # above cannot reach it. The lockfile-to-ecosystem check in # e2e/tests/supply-chain.e2e.test.ts asserts coverage per ECOSYSTEM, so it @@ -150,6 +150,13 @@ updates: # The workspace root, where Cargo.toml and Cargo.lock live. Not `/`, and # not a crate directory — same trap as the protect-ffi entry documents. directory: /packages/eql + # Lockfile only: this entry must never edit a manifest. The workspace + # depends by path on root-workspace crates (see the ignores below), so a + # requirement it would raise can live in the ROOT Cargo.toml, outside this + # workspace; editing it there without the root Cargo.lock broke every root + # `--locked` build (#1205, #1207). An update that needs a manifest change is + # dropped rather than proposed, and is made by hand. + versioning-strategy: lockfile-only # Monthly, matching protect-ffi and for the same reason: validating a # Cargo.lock bump here means the sharded SQLx matrix in test-eql.yml, which # compiles the full dependency tree and runs against live Postgres with @@ -195,6 +202,27 @@ updates: # a registry version for a crate we release here would fight the lockstep # version hook (scripts/sync-lockstep-versions.mjs). - dependency-name: "eql-bindings" + # Inherited from the ROOT workspace. tests/encryption depends by path on + # packages/stack-kms and packages/stack-encrypt, and eql-bindings on + # stack-encrypt behind its optional `stack-encrypt` feature. Those crates + # declare these `{ workspace = true }`, so their requirements come from + # the root Cargo.toml even when built here. The root entry below ignores + # them because they move by hand with cipherstash-suite: the root + # manifest pins recipher and cllw-ore with `=`, and so does the Go guest. + # Without these lines this entry bumped them in this lockfile and, to + # satisfy the exact pins, rewrote the root Cargo.toml without touching + # the root Cargo.lock or the guest's pin, breaking every root `--locked` + # build and the guest (#1205, #1207). vitaminc* also keeps eql-bindings + # on the line stack-encrypt and the guest build against. + # versioning-strategy above already stops the manifest edit; these keep + # the lockfile from moving these crates apart from the root's. + # scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs fails when + # the root entry ignores a crate this entry does not, or when this entry + # stops being lockfile-only. + - dependency-name: "recipher" + - dependency-name: "cllw-ore" + - dependency-name: "zerokms-protocol" + - dependency-name: "vitaminc*" # Major bumps are reviewed and applied manually, not by Dependabot. - dependency-name: "*" update-types: diff --git a/scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs b/scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs new file mode 100644 index 000000000..3673dccd2 --- /dev/null +++ b/scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs @@ -0,0 +1,174 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, join, relative, resolve, sep } from 'node:path' +import yaml from 'js-yaml' +import { beforeAll, describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * The `/packages/eql` Dependabot entry must ignore every crate the root + * workspace's entry ignores, for as long as the EQL workspace depends by path + * on root-workspace crates. + * + * The EQL workspace is a separate Cargo workspace, but not an independent one: + * `tests/encryption` depends by path on `stack-kms` and `stack-encrypt`, and + * `eql-bindings` on `stack-encrypt` (behind its optional `stack-encrypt` + * feature). Those crates declare `recipher`, `cllw-ore` and `vitaminc-*` as + * `{ workspace = true }`. Built inside the EQL workspace, those requirements + * still come from the ROOT `Cargo.toml`. The root entry ignores these crates + * because they move by hand with cipherstash-suite: the root manifest pins + * `recipher` and `cllw-ore` with `=`, and so does the Go guest. With no + * matching ignore, the EQL entry's monthly PR bumped them in the EQL lock and, + * to satisfy the exact pins, edited the root `Cargo.toml` without updating the + * root `Cargo.lock` or the guest's pin (#1205). Every root `--locked` build and + * the Go guest failed (#1207). + * + * Two defences, both held here: + * - the EQL entry is `versioning-strategy: lockfile-only`, so it can never + * edit a manifest, the root one included; + * - it ignores every crate the root entry moves by hand, so its lockfile does + * not move those crates apart from the root's. The two ignore lists are + * hand-written copies, so this compares them. + */ + +const DEPENDABOT = join(REPO_ROOT, '.github/dependabot.yml') +const EQL_DIR = '/packages/eql' +const ROOT_DIR = '/' + +/** + * Crates the root entry ignores that the EQL entry need not, each with the + * reason. An entry naming a crate the root entry no longer ignores is stale + * and fails. + */ +const EXEMPT = new Map([ + // ['crate-name', 'why the EQL workspace cannot resolve it through a path dependency'], +]) + +/** The `cargo` entry covering `dir`, from `directory:` or `directories:`. */ +function cargoEntry(config, dir) { + const matches = config.updates.filter( + (u) => + u['package-ecosystem'] === 'cargo' && + (u.directory === dir || (u.directories ?? []).includes(dir)), + ) + expect(matches, `exactly one cargo entry covers ${dir}`).toHaveLength(1) + return matches[0] +} + +/** + * The crate names an entry ignores outright. An ignore that carries + * `update-types` (the semver-major rule) only narrows which updates are + * proposed, so it is not an ignore of the crate. + */ +function ignoredCrates(entry) { + return new Set( + (entry.ignore ?? []) + .filter((rule) => !rule['update-types']) + .map((rule) => rule['dependency-name']), + ) +} + +/** The root workspace's member directories, repo-relative. */ +function rootMembers() { + const manifest = readFileSync(join(REPO_ROOT, 'Cargo.toml'), 'utf8') + const block = manifest.match(/^members\s*=\s*\[([\s\S]*?)\]/m) + expect( + block, + 'the root Cargo.toml lists its workspace members', + ).not.toBeNull() + return [...block[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]) +} + +/** + * Every root-workspace member an EQL-workspace manifest depends on by path, + * as `{ manifest, member }` pairs. Read from the tracked manifests so a new + * path dependency is covered the day it lands. + */ +function eqlPathDependenciesIntoRoot() { + const members = new Set(rootMembers()) + const manifests = execFileSync( + 'git', + ['ls-files', '--', 'packages/eql/**/Cargo.toml', 'packages/eql/Cargo.toml'], + { cwd: REPO_ROOT, encoding: 'utf8' }, + ) + .split('\n') + .filter(Boolean) + const found = [] + for (const manifest of manifests) { + const text = readFileSync(join(REPO_ROOT, manifest), 'utf8') + for (const [, path] of text.matchAll(/\bpath\s*=\s*"([^"]+)"/g)) { + // Repo-relative with `/`, the separator Cargo.toml's `members` uses. + const member = relative( + REPO_ROOT, + resolve(REPO_ROOT, dirname(manifest), path), + ) + .split(sep) + .join('/') + if (members.has(member)) found.push({ manifest, member }) + } + } + return found +} + +describe('the /packages/eql Dependabot entry ignores what it inherits from the root workspace', () => { + let eqlEntry + let rootIgnored + let eqlIgnored + let pathDeps + + // In beforeAll, not at module scope, so a config-shape failure (say, two + // entries covering one directory) is reported against a named test. + beforeAll(() => { + const config = yaml.load(readFileSync(DEPENDABOT, 'utf8')) + eqlEntry = cargoEntry(config, EQL_DIR) + rootIgnored = ignoredCrates(cargoEntry(config, ROOT_DIR)) + eqlIgnored = ignoredCrates(eqlEntry) + pathDeps = eqlPathDependenciesIntoRoot() + }) + + it('never edits a manifest: the EQL entry is lockfile-only', () => { + expect( + eqlEntry['versioning-strategy'], + `the ${EQL_DIR} entry must be \`versioning-strategy: lockfile-only\`. Its path ` + + 'dependencies inherit requirements from the ROOT Cargo.toml, so any manifest edit ' + + 'it makes can land outside its own workspace.', + ).toBe('lockfile-only') + }) + + it('finds the root ignore list it compares against', () => { + expect(rootIgnored.size).toBeGreaterThan(0) + }) + + it('finds the EQL path dependencies into root-workspace crates', () => { + // If these go away the EQL workspace stops inheriting root requirements, + // and this whole file can be retired rather than left vacuously green. + expect( + pathDeps.map((d) => d.member), + 'the EQL workspace depends by path on root-workspace crates', + ).not.toHaveLength(0) + }) + + it('ignores every crate the root entry ignores, unless exempted with a reason', () => { + const missing = [...rootIgnored].filter( + (name) => !eqlIgnored.has(name) && !EXEMPT.has(name), + ) + expect( + missing, + `the root cargo entry ignores these but the ${EQL_DIR} entry does not. The EQL workspace ` + + `inherits root requirements through path dependencies on ` + + `${[...new Set(pathDeps.map((d) => d.member))].join(', ')}, so an EQL Dependabot PR ` + + `would edit the root workspace's pins. Add them to the ${EQL_DIR} entry's ignore list, ` + + `or exempt one with a reason here if the EQL workspace provably cannot resolve it.`, + ).toEqual([]) + }) + + it('carries no stale exemption', () => { + const stale = [...EXEMPT.keys()].filter( + (name) => !rootIgnored.has(name) || eqlIgnored.has(name), + ) + expect( + stale, + 'exempt crates the root entry no longer ignores, or that the EQL entry ignores anyway', + ).toEqual([]) + }) +}) From 4153d0ac27084203a4b0bd207ccccef079b31e56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:35:10 +0000 Subject: [PATCH 2/2] ci(dependabot): make every cargo entry lockfile-only, and hold it by path edges Addresses the review on this PR. - Generalise the lockfile-only guard. A new test reads every path dependency across the cargo Dependabot workspaces and requires `versioning-strategy: lockfile-only` on each entry at either end of one. That covers protect-ffi (path dep on eql-bindings) and the root entry (the fuzz crates and Go guests depend on root crates, and EQL depends on stack-encrypt/stack-kms), both the #1205 shape one edge over. Both entries are now lockfile-only. - State in each lockfile-only comment that it also drops a security update needing a manifest edit, and that osv-scanner still reports it; reword the "Security fixes are unaffected" line. - Count an EXEMPT entry only when its reason is non-blank. - Rewrap the long EQL entry comment. - Relax eql-codegen's `prettyplease = "=0.2.37"` pin to "0.2": nothing records a reason for it, rustfmt formats the output last, and under lockfile-only Dependabot could never move an exact pin. Cargo.lock is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MvUm3CCvX9dh2AZMh9vXct --- .github/dependabot.yml | 49 ++++-- packages/eql/crates/eql-codegen/Cargo.toml | 2 +- .../dependabot-cargo-cross-workspace.test.mjs | 159 ++++++++++++++++++ .../dependabot-eql-inherited-ignores.test.mjs | 43 +++-- 4 files changed, 227 insertions(+), 26 deletions(-) create mode 100644 scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 66e82f7ee..d3257df9b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -66,6 +66,16 @@ updates: # found" on a log page nobody visits, and the symptom is simply that no PR # ever arrives. The e2e test asserts this path holds a Cargo.toml. directory: /languages/typescript/packages/protect-ffi + # Lockfile only, like the EQL entry below and for the same reason: this + # workspace depends by path on eql-bindings, a member of the EQL + # workspace. A requirement this entry raised could be written into + # packages/eql/crates/eql-bindings/Cargo.toml without the EQL Cargo.lock, + # the #1205 shape one edge over. An update that needs a manifest change, + # in this workspace or that one, is dropped and made by hand. This applies + # to Dependabot security updates too; osv-scanner still reports the + # advisory. Held by + # scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs. + versioning-strategy: lockfile-only # Monthly, where npm and actions are weekly. The deviation is about # validation cost, not risk appetite: a Cargo.lock bump is checked by # tests-rust.yml — one Blacksmith job, 45-minute timeout, cargo test + @@ -140,12 +150,13 @@ updates: # ── Cargo (packages/eql — the second Rust workspace) ─────────── # The EQL subtree brought a second Cargo workspace: six members # (crates/eql-{domains,codegen,tests-macros,bindings}, tests/sqlx and - # tests/encryption) and its own Cargo.lock. It needs its own entry because Dependabot's cargo - # `directory:` is a single workspace root, not a glob — the protect-ffi entry - # above cannot reach it. The lockfile-to-ecosystem check in - # e2e/tests/supply-chain.e2e.test.ts asserts coverage per ECOSYSTEM, so it - # was already satisfied by that entry and would NOT have caught this gap; - # the symptom would simply have been that no PR ever arrived. + # tests/encryption) and its own Cargo.lock. It needs its own entry because + # Dependabot's cargo `directory:` is a single workspace root, not a glob — + # the protect-ffi entry above cannot reach it. The lockfile-to-ecosystem + # check in e2e/tests/supply-chain.e2e.test.ts asserts coverage per + # ECOSYSTEM, so it was already satisfied by that entry and would NOT have + # caught this gap; the symptom would simply have been that no PR ever + # arrived. - package-ecosystem: cargo # The workspace root, where Cargo.toml and Cargo.lock live. Not `/`, and # not a crate directory — same trap as the protect-ffi entry documents. @@ -156,12 +167,20 @@ updates: # workspace; editing it there without the root Cargo.lock broke every root # `--locked` build (#1205, #1207). An update that needs a manifest change is # dropped rather than proposed, and is made by hand. + # + # That includes Dependabot SECURITY updates: `versioning-strategy` governs + # them as well as version updates, so a security fix that needs a + # requirement raised gets no PR from this entry. osv-scanner still reports + # the advisory (it reaches this lockfile with `--recursive ./`), and the fix + # is made by hand. Held, with the other two cargo entries, by + # scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs. versioning-strategy: lockfile-only # Monthly, matching protect-ffi and for the same reason: validating a # Cargo.lock bump here means the sharded SQLx matrix in test-eql.yml, which # compiles the full dependency tree and runs against live Postgres with - # CipherStash credentials. Security fixes are unaffected — those are driven - # by alerts, not by `schedule`. + # CipherStash credentials. The schedule does not delay security fixes — + # those are driven by alerts, not by `schedule` — though `lockfile-only` + # above still drops one that needs a manifest edit. # # No `day:`, for the reason recorded above: it applies to `interval: weekly` # and would be configuration that does nothing. @@ -217,8 +236,7 @@ updates: # versioning-strategy above already stops the manifest edit; these keep # the lockfile from moving these crates apart from the root's. # scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs fails when - # the root entry ignores a crate this entry does not, or when this entry - # stops being lockfile-only. + # the root entry ignores a crate this entry does not. - dependency-name: "recipher" - dependency-name: "cllw-ore" - dependency-name: "zerokms-protocol" @@ -241,6 +259,17 @@ updates: - /packages/stack-encrypt/fuzz - /languages/golang/encrypt/guest - /languages/golang/auth/guest + # Lockfile only, like the other two cargo entries. Every one of these + # workspaces shares crates by path with another: the fuzz crates and both + # guests depend on root-workspace crates, the encrypt guest also on + # eql-bindings, and the EQL workspace on stack-encrypt and stack-kms. Those + # root crates take `{ workspace = true }` requirements from the root + # Cargo.toml, so a manifest edit made for any one of these lockfiles can + # leave the others stale under `--locked`. An update that needs a manifest + # change, security updates included, is dropped and made by hand; osv-scanner + # still reports the advisory. Held by + # scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs. + versioning-strategy: lockfile-only # Monthly, matching the other two cargo entries. schedule: interval: monthly diff --git a/packages/eql/crates/eql-codegen/Cargo.toml b/packages/eql/crates/eql-codegen/Cargo.toml index b245640ca..137fea3f4 100644 --- a/packages/eql/crates/eql-codegen/Cargo.toml +++ b/packages/eql/crates/eql-codegen/Cargo.toml @@ -13,7 +13,7 @@ thiserror = "2" quote = "1" proc-macro2 = "1" syn = { version = "2", features = ["full"] } -prettyplease = "=0.2.37" +prettyplease = "0.2" [[bin]] name = "eql-codegen" diff --git a/scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs b/scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs new file mode 100644 index 000000000..81ddf6f4a --- /dev/null +++ b/scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs @@ -0,0 +1,159 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { join, relative, resolve, sep } from 'node:path' +import yaml from 'js-yaml' +import { beforeAll, describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * Every cargo Dependabot entry whose workspaces share crates with another + * workspace by path must be `versioning-strategy: lockfile-only`. + * + * Each cargo `directory:` is one Cargo workspace with its own Cargo.lock, and + * a Dependabot PR updates exactly that one lockfile. A path dependency crosses + * that line in both directions: + * - OUTWARD: a workspace that depends by path on another workspace's crate + * resolves that crate's requirements, including any `{ workspace = true }` + * ones from the other workspace's root Cargo.toml. To satisfy a bump it may + * edit those manifests, outside its own workspace, and leave the other + * workspace's Cargo.lock behind. That is #1205: the EQL entry rewrote the + * root Cargo.toml, and every root `--locked` build failed (#1207). + * - INWARD: a workspace whose crate another workspace depends on by path can + * raise a requirement in its own manifest and update its own lockfile, and + * the depending workspace's Cargo.lock is then stale under `--locked`. + * + * `lockfile-only` stops both: the entry never edits a manifest. An update that + * needs one is dropped and made by hand, across every affected lockfile. + * + * The edges are read from the tracked manifests, so a new path dependency is + * covered the day it lands. + */ + +const DEPENDABOT = join(REPO_ROOT, '.github/dependabot.yml') + +/** Repo-relative, `/`-separated, no leading slash: the form Cargo uses. */ +function repoRel(abs) { + return relative(REPO_ROOT, abs).split(sep).join('/') +} + +function readManifest(dir) { + return readFileSync(join(REPO_ROOT, dir, 'Cargo.toml'), 'utf8') +} + +/** + * The crate directories of the workspace rooted at `dir`: its `members`, plus + * the root itself when it is also a package (a detached single-crate + * workspace, such as a fuzz crate or a Go guest). + */ +function workspaceCrates(dir, manifest) { + const crates = [] + if (/^\[package\]/m.test(manifest)) crates.push(dir) + const block = manifest.match(/^members\s*=\s*\[([\s\S]*?)\]/m) + if (block) { + for (const [, member] of block[1].matchAll(/"([^"]+)"/g)) { + expect( + member, + `${dir}/Cargo.toml: glob members are not expanded here`, + ).not.toMatch(/[*?[]/) + crates.push(repoRel(resolve(REPO_ROOT, dir, member))) + } + } + return crates +} + +/** + * The crates a manifest depends on by path, repo-relative. A `path =` that + * names a file (`[lib]`, `[[bin]]`) is not a dependency and is skipped. + */ +function pathDependencies(crateDir, manifest) { + const deps = [] + for (const [, path] of manifest.matchAll(/\bpath\s*=\s*"([^"]+)"/g)) { + const abs = resolve(REPO_ROOT, crateDir, path) + if (!existsSync(abs) || !statSync(abs).isDirectory()) continue + if (!existsSync(join(abs, 'Cargo.toml'))) continue + deps.push(repoRel(abs)) + } + return deps +} + +/** + * Every cross-workspace path dependency, as + * `{ from, to, fromDir, toDir }`: crate `from` in the workspace at `fromDir` + * depends on crate `to` in the workspace at `toDir`. `toDir` is null when no + * Dependabot cargo directory covers `to`. + */ +function crossWorkspaceEdges(workspaces) { + const owner = new Map() + for (const [dir, crates] of workspaces) { + for (const crate of crates) owner.set(crate, dir) + } + const edges = [] + for (const [dir, crates] of workspaces) { + for (const crate of crates) { + for (const to of pathDependencies(crate, readManifest(crate))) { + const toDir = owner.get(to) ?? null + const seen = edges.some((e) => e.from === crate && e.to === to) + if (toDir !== dir && !seen) { + edges.push({ from: crate, to, fromDir: dir, toDir }) + } + } + } + } + return edges +} + +/** The directories of a Dependabot entry, without the leading `/`. */ +function entryDirs(entry) { + const dirs = entry.directories ?? [entry.directory] + return dirs.map((d) => d.replace(/^\/+/, '').replace(/\/+$/, '')) +} + +describe('cargo Dependabot entries that share crates across workspaces never edit a manifest', () => { + let entries + let edges + + beforeAll(() => { + const config = yaml.load(readFileSync(DEPENDABOT, 'utf8')) + entries = config.updates.filter((u) => u['package-ecosystem'] === 'cargo') + const workspaces = new Map() + for (const entry of entries) { + for (const dir of entryDirs(entry)) { + workspaces.set(dir, workspaceCrates(dir, readManifest(dir))) + } + } + edges = crossWorkspaceEdges(workspaces) + }) + + it('finds cross-workspace path dependencies', () => { + // If these all go away the rule below has nothing to hold, and this file + // can be retired rather than left vacuously green. + expect(edges).not.toHaveLength(0) + }) + + it('covers every crate a path dependency reaches', () => { + const uncovered = edges.filter((e) => e.toDir === null) + expect( + uncovered.map((e) => `${e.from} -> ${e.to}`), + 'path dependencies on crates no cargo Dependabot directory covers', + ).toEqual([]) + }) + + it('makes every entry on either end of one lockfile-only', () => { + const involved = new Set(edges.flatMap((e) => [e.fromDir, e.toDir])) + const offenders = entries + .filter((entry) => entryDirs(entry).some((d) => involved.has(d))) + .filter((entry) => entry['versioning-strategy'] !== 'lockfile-only') + .map((entry) => { + const dirs = new Set(entryDirs(entry)) + const why = edges + .filter((e) => dirs.has(e.fromDir) || dirs.has(e.toDir)) + .map((e) => `${e.from} -> ${e.to}`) + return `${(entry.directories ?? [entry.directory]).join(', ')}: ${why.join('; ')}` + }) + expect( + offenders, + 'these cargo entries share crates by path with another workspace, so a manifest ' + + "edit can leave the other workspace's Cargo.lock behind. Set " + + '`versioning-strategy: lockfile-only` on each.', + ).toEqual([]) + }) +}) diff --git a/scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs b/scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs index 3673dccd2..16d80a139 100644 --- a/scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs +++ b/scripts/__tests__/dependabot-eql-inherited-ignores.test.mjs @@ -23,10 +23,12 @@ import { REPO_ROOT } from './lib/repo-root.mjs' * root `Cargo.lock` or the guest's pin (#1205). Every root `--locked` build and * the Go guest failed (#1207). * - * Two defences, both held here: + * Two defences: * - the EQL entry is `versioning-strategy: lockfile-only`, so it can never - * edit a manifest, the root one included; - * - it ignores every crate the root entry moves by hand, so its lockfile does + * edit a manifest, the root one included. That one is held, for every cargo + * entry a path dependency crosses into or out of, by + * dependabot-cargo-cross-workspace.test.mjs; + * - held here: it ignores every crate the root entry moves by hand, so its lockfile does * not move those crates apart from the root's. The two ignore lists are * hand-written copies, so this compares them. */ @@ -44,6 +46,17 @@ const EXEMPT = new Map([ // ['crate-name', 'why the EQL workspace cannot resolve it through a path dependency'], ]) +/** + * The crates `rootIgnored` names that `eqlIgnored` does not, less those + * `exempt` excuses. Only a written reason excuses one: an exemption whose + * reason is empty is no exemption. + */ +function unexempted(rootIgnored, eqlIgnored, exempt) { + return [...rootIgnored].filter( + (name) => !eqlIgnored.has(name) && !exempt.get(name)?.trim(), + ) +} + /** The `cargo` entry covering `dir`, from `directory:` or `directories:`. */ function cargoEntry(config, dir) { const matches = config.updates.filter( @@ -126,15 +139,6 @@ describe('the /packages/eql Dependabot entry ignores what it inherits from the r pathDeps = eqlPathDependenciesIntoRoot() }) - it('never edits a manifest: the EQL entry is lockfile-only', () => { - expect( - eqlEntry['versioning-strategy'], - `the ${EQL_DIR} entry must be \`versioning-strategy: lockfile-only\`. Its path ` + - 'dependencies inherit requirements from the ROOT Cargo.toml, so any manifest edit ' + - 'it makes can land outside its own workspace.', - ).toBe('lockfile-only') - }) - it('finds the root ignore list it compares against', () => { expect(rootIgnored.size).toBeGreaterThan(0) }) @@ -149,9 +153,7 @@ describe('the /packages/eql Dependabot entry ignores what it inherits from the r }) it('ignores every crate the root entry ignores, unless exempted with a reason', () => { - const missing = [...rootIgnored].filter( - (name) => !eqlIgnored.has(name) && !EXEMPT.has(name), - ) + const missing = unexempted(rootIgnored, eqlIgnored, EXEMPT) expect( missing, `the root cargo entry ignores these but the ${EQL_DIR} entry does not. The EQL workspace ` + @@ -162,6 +164,17 @@ describe('the /packages/eql Dependabot entry ignores what it inherits from the r ).toEqual([]) }) + it('counts an exemption only when it gives a reason', () => { + const root = new Set(['recipher', 'cllw-ore', 'vitaminc*']) + const eql = new Set() + const exempt = new Map([ + ['recipher', 'the EQL workspace resolves no crate that requires it'], + ['cllw-ore', ''], + ['vitaminc*', ' '], + ]) + expect(unexempted(root, eql, exempt)).toEqual(['cllw-ore', 'vitaminc*']) + }) + it('carries no stale exemption', () => { const stale = [...EXEMPT.keys()].filter( (name) => !rootIgnored.has(name) || eqlIgnored.has(name),