From d74bb5b0939af01e43e585e1c291a02f93c00cf4 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 5 Oct 2026 09:46:31 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20`bumpy=20ci=20deps`=20=E2=80=94=20a?= =?UTF-8?q?uto=20bump=20files=20for=20dependency=20update=20PRs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpy/dependency-update-bump-files.md | 5 + docs/cli.md | 25 ++- docs/configuration.md | 2 + docs/github-actions.md | 47 ++++ packages/bumpy/src/cli.ts | 16 +- packages/bumpy/src/commands/ci.ts | 86 +++++++- packages/bumpy/src/commands/generate.ts | 51 ++++- packages/bumpy/src/core/dep-bump-files.ts | 202 ++++++++++++++++++ .../bumpy/test/core/dep-bump-files.test.ts | 192 +++++++++++++++++ 9 files changed, 614 insertions(+), 12 deletions(-) create mode 100644 .bumpy/dependency-update-bump-files.md create mode 100644 packages/bumpy/src/core/dep-bump-files.ts create mode 100644 packages/bumpy/test/core/dep-bump-files.test.ts diff --git a/.bumpy/dependency-update-bump-files.md b/.bumpy/dependency-update-bump-files.md new file mode 100644 index 0000000..b786192 --- /dev/null +++ b/.bumpy/dependency-update-bump-files.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +Add `bumpy ci deps` for Dependabot / Renovate PRs (modeled on changesets-dependencies-action). It diffs each package's `dependencies` / `peerDependencies` / `optionalDependencies` (plus `releaseTriggeringDevDeps`, and catalog updates) against the base branch, writes one patch bump file per affected package listing the added / updated / removed deps, and commits + pushes them to the PR branch. Re-running is idempotent and removes stale files. `bumpy generate --deps` does the same locally without committing. diff --git a/docs/cli.md b/docs/cli.md index f3cde97..8472567 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -158,13 +158,15 @@ Auto-create bump files from commits on the current branch. Works with any commit bumpy generate bumpy generate --dry-run bumpy generate --from v1.0.0 # override: scan from a specific ref instead of branch base +bumpy generate --deps # one patch bump file per package whose dependencies changed ``` -| Flag | Description | -| --------------- | ------------------------------------------------------------------------ | -| `--from ` | Git ref to scan from (default: branch point from `baseBranch` in config) | -| `--dry-run` | Preview without creating files | -| `--name ` | Bump file filename | +| Flag | Description | +| --------------- | ------------------------------------------------------------------------------------------- | +| `--from ` | Git ref to scan from (default: branch point from `baseBranch` in config) | +| `--dry-run` | Preview without creating files | +| `--name ` | Bump file filename (with `--deps`: filename suffix, default: current branch) | +| `--deps` | Generate from dependency changes instead of commits — see [`bumpy ci deps`](#bumpy-ci-deps) | **How commits are mapped:** @@ -213,6 +215,19 @@ bumpy ci comment --body-file ./bumpy-comment/comment.md It needs no checkout and no bumpy project — it only posts. Under `workflow_run` it resolves the target PR from the **trusted** event (`head_sha`), never from the artifact, and treats the body as untrusted text. A missing or empty body file is a no-op. Requires `GH_TOKEN`. +## `bumpy ci deps` + +For dependency-update PRs (Dependabot, Renovate). Writes one patch bump file per package whose `dependencies` / `peerDependencies` / `optionalDependencies` (or [`releaseTriggeringDevDeps`](configuration.md#release-triggering-devdependencies)) changed, as `.bumpy/deps-pr-.md`, then commits and pushes them to the PR branch. Idempotent — re-running rewrites the files and removes stale ones. See [Dependency update PRs](github-actions.md#dependency-update-prs-dependabot--renovate) for the workflow. + +```bash +bumpy ci deps +bumpy ci deps --no-push +``` + +| Flag | Description | +| ----------- | ------------------------------------ | +| `--no-push` | Commit the bump files but don't push | + ## `bumpy ci plan` CI command that reports what `ci release` would do, without acting. Outputs JSON to stdout and sets GitHub Actions step outputs so you can conditionally run expensive steps (builds, etc.) only when needed. diff --git a/docs/configuration.md b/docs/configuration.md index b1652d0..6543fd0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,6 +52,8 @@ To relax additional fields (e.g. treat `scripts` changes as non-releasing too), } ``` +For runtime dependency updates from bots, [`bumpy ci deps`](github-actions.md#dependency-update-prs-dependabot--renovate) can write the bump files automatically. + bumpy errs toward requiring a bump file whenever it can't compare cleanly — a brand-new `package.json`, or one it can't parse. ### Dependency bump rules diff --git a/docs/github-actions.md b/docs/github-actions.md index b4b2374..5b59cc5 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -107,6 +107,53 @@ Point `workflows: [...]` at the **name** of whatever runs your check (your exist > **Why can't the fork run post it itself?** A fork's `pull_request` token is read-only at issuance and enforced server-side — REST, GraphQL, `gh`, and raw `curl` all 403 on a comment write, and secrets aren't exposed either. The write has to originate from a privileged base-repo run, which is exactly what `workflow_run` provides. +## Dependency update PRs (Dependabot / Renovate) + +A dev-only update (`devDependencies`) never needs a bump file — see [change detection](configuration.md#change-detection-and-packagejson-fields). An update to `dependencies`, `peerDependencies` or `optionalDependencies` does, since it changes what your consumers install. Rather than adding those by hand, `bumpy ci deps` writes them for you (the bumpy equivalent of [changesets-dependencies-action](https://github.com/the-guild-org/changesets-dependencies-action)): + +- diffs each managed package's dependencies against the base branch (catalog updates included; `workspace:` deps skipped — bumpy's own propagation handles those; `devDependencies` only if listed in [`releaseTriggeringDevDeps`](configuration.md#release-triggering-devdependencies)) +- writes one **patch** bump file per affected package, `.bumpy/deps-pr-.md`, listing each added / updated / removed dependency with a link to npm +- commits and pushes them to the PR branch — re-running (e.g. after Dependabot rebases) rewrites the files and deletes ones that no longer apply + +Run it before `ci check`, in the same job, so the check sees the new files: + +```yaml +# .github/workflows/bumpy-deps.yaml +name: Bumpy (dependency PRs) +on: pull_request + +jobs: + deps: + if: github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]' + runs-on: ubuntu-latest + permissions: + contents: write # push the bump-file commit + pull-requests: write # ci check comment + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + - run: bunx @varlock/bumpy ci deps + env: + GH_TOKEN: ${{ github.token }} + BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} + - run: bunx @varlock/bumpy ci check + env: + GH_TOKEN: ${{ github.token }} +``` + +Then skip bumpy's regular PR check for those actors (`if: github.actor != 'dependabot[bot]' && ...`), or leave it — it will pass on the re-run triggered by the pushed commit. + +Things to know: + +- **Use `BUMPY_GH_TOKEN` if checks are required.** A commit pushed with the default `GITHUB_TOKEN` doesn't trigger new workflow runs, so required status checks on the new head commit stay pending. Workflows triggered by Dependabot only see **Dependabot secrets** — add `BUMPY_GH_TOKEN` under _Settings → Secrets and variables → Dependabot_ as well as _Actions_. See [Token setup](#token-setup). +- **Dependabot stops auto-rebasing PRs that have commits from someone else.** Comment `@dependabot recreate` to get a fresh branch; the workflow then regenerates the bump files. +- **Fork PRs are skipped** — their branch can't be pushed to. +- `ci deps` sets a `changed` step output (`true` / `false`). +- Locally, `bumpy generate --deps` writes the same files (named after the branch) without committing. + ## Release workflow (recommended: split jobs) The recommended release workflow splits version-PR maintenance from publishing into separate jobs. Only the publish job carries `id-token: write` and npm credentials, and it runs inside a GitHub Environment — so a rogue workflow elsewhere in the repo can't request an OIDC token that npm will accept. diff --git a/packages/bumpy/src/cli.ts b/packages/bumpy/src/cli.ts index 68b6c6c..d6237c8 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -96,6 +96,7 @@ async function main() { from: flags.from as string | undefined, dryRun: flags['dry-run'] === true, name: flags.name as string | undefined, + deps: flags.deps === true, }); break; } @@ -157,6 +158,9 @@ async function main() { noFail: ciFlags['no-fail'] === true, emitComment: typeof emitComment === 'string' ? emitComment : undefined, }); + } else if (subcommand === 'deps') { + const { ciDepsCommand } = await import('./commands/ci.ts'); + await ciDepsCommand(rootDir, { noPush: ciFlags['no-push'] === true }); } else if (subcommand === 'plan') { const { ciPlanCommand } = await import('./commands/ci.ts'); await ciPlanCommand(rootDir); @@ -198,7 +202,7 @@ async function main() { await ciSetupCommand(rootDir); } else { log.error( - `Unknown ci subcommand: ${subcommand}. Use "ci check", "ci comment", "ci plan", "ci release", or "ci setup".`, + `Unknown ci subcommand: ${subcommand}. Use "ci check", "ci comment", "ci deps", "ci plan", "ci release", or "ci setup".`, ); process.exit(1); } @@ -263,7 +267,7 @@ function printHelp() { add Create a new bump file --none Set all changed packages to "none" (acknowledge without bumping) --empty Create an empty bump file (no releases needed) - generate Generate bump file from branch commits + generate Generate bump file from branch commits (or dependency changes with --deps) status Show pending releases check Verify changed packages have bump files (for git hooks) --strict Fail if any changed package is uncovered (default: only fail if no bump files at all) @@ -277,6 +281,7 @@ function printHelp() { (--snapshot : transient preview publish to a throwaway dist-tag) ci check PR check — report pending releases, comment on PR ci comment Post a pre-rendered comment (workflow_run half of the fork-comment split) + ci deps Dependabot/Renovate PRs — commit patch bump files for dependency updates ci plan Report what ci release would do (JSON + GitHub Actions outputs) ci release Release — create version PR or auto-publish ci setup Set up a token for triggering CI on version PRs @@ -290,7 +295,12 @@ function printHelp() { Generate options: --from Git ref to scan from (default: branch point from baseBranch) --dry-run Preview without creating a bump file - --name Bump file filename + --name Bump file filename (with --deps: file name suffix, default: branch name) + --deps One patch bump file per package whose dependencies changed + (dependencies/peerDependencies/optionalDependencies + releaseTriggeringDevDeps) + + CI deps options: + --no-push Write + commit the bump files but don't push Status options: --json Output as JSON (includes dirs, bumpFiles, packageNames) diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 7beb8d8..4788833 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -1,10 +1,11 @@ import { log, colorize } from '../utils/logger.ts'; -import { loadConfig } from '../core/config.ts'; +import { getBumpyDir, loadConfig } from '../core/config.ts'; import { findChangedPackages } from './check.ts'; import { discoverWorkspace } from '../core/workspace.ts'; import { DependencyGraph } from '../core/dep-graph.ts'; import { readBumpFiles, filterBranchBumpFiles, recoverDeletedBumpFiles } from '../core/bump-file.ts'; -import { getChangedFiles, withGitToken } from '../core/git.ts'; +import { getBaseCompareRef, getChangedFiles, getCurrentBranch, withGitToken } from '../core/git.ts'; +import { detectDependencyChanges, syncDependencyBumpFiles } from '../core/dep-bump-files.ts'; import { assembleReleasePlan } from '../core/release-plan.ts'; import { channelNames, @@ -20,7 +21,7 @@ import { randomName } from '../utils/names.ts'; import { detectPackageManager } from '../utils/package-manager.ts'; import { createHash } from 'node:crypto'; import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { relative, resolve } from 'node:path'; import { resolveCommitMessage } from '../core/commit-message.ts'; import type { BumpyConfig, BumpFile, PackageConfig, PackageManager, ReleasePlan, PlannedRelease } from '../types.ts'; @@ -285,6 +286,85 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi } } +// ---- ci deps ---- + +interface DepsOptions { + noPush?: boolean; +} + +/** + * Dependency-update PRs (Dependabot, Renovate, …): write one patch bump file per package + * whose release-relevant dependencies changed, then commit + push them to the PR branch. + * The bumpy equivalent of the-guild-org/changesets-dependencies-action. + * + * Files are named `deps-pr-.md`, so re-running on a new commit (or after a + * Dependabot rebase) rewrites them and removes ones that no longer apply. Run it before + * `ci check` in the same job so the check sees the new files. + */ +export async function ciDepsCommand(rootDir: string, opts: DepsOptions): Promise { + const prNumber = detectPrNumber(); + const headRef = process.env.GITHUB_HEAD_REF || detectPrBranch(rootDir); + if (!prNumber || !headRef) { + throw new Error('`bumpy ci deps` must run on a pull request (could not detect the PR number / head branch).'); + } + validatePrNumber(prNumber); + validateBranchName(headRef); + + if (isForkPr()) { + log.warn('Skipping — this PR is from a fork, so bump files cannot be pushed to its branch.'); + writeGitHubOutput('changed', 'false'); + return; + } + + const config = await loadConfig(rootDir); + + // `actions/checkout` on pull_request checks out a detached merge commit — move onto + // the actual PR branch so the bump-file commit can be pushed to it. + if (getCurrentBranch({ cwd: rootDir }) !== headRef) { + runArgs(['git', 'fetch', 'origin', `refs/heads/${headRef}`], { cwd: rootDir }); + runArgs(['git', 'checkout', '-B', headRef, 'FETCH_HEAD'], { cwd: rootDir }); + } + + const { packages } = await discoverWorkspace(rootDir, config); + const baseRef = getBaseCompareRef(rootDir, config.baseBranch); + const changes = await detectDependencyChanges(rootDir, config, packages, baseRef); + const { written, removed } = await syncDependencyBumpFiles(rootDir, changes, `pr${prNumber}`, baseRef); + for (const id of written) log.dim(` .bumpy/${id}.md`); + for (const id of removed) log.dim(` removed .bumpy/${id}.md`); + + const bumpyRelDir = relative(rootDir, getBumpyDir(rootDir)); + const dirty = tryRunArgs(['git', 'status', '--porcelain', '--', bumpyRelDir], { cwd: rootDir }); + if (!dirty) { + log.info(changes.size === 0 ? 'No dependency changes detected.' : 'Dependency bump files are up to date.'); + writeGitHubOutput('changed', 'false'); + return; + } + + ensureGitIdentity(rootDir, config); + runArgs(['git', 'add', '-A', '--', bumpyRelDir], { cwd: rootDir }); + runArgs(['git', 'commit', '--no-verify', '-F', '-'], { + cwd: rootDir, + input: 'chore(deps): add bump files for dependency updates', + }); + writeGitHubOutput('changed', 'true'); + + if (opts.noPush) { + log.success(`🐸 Committed dependency bump files for ${changes.size} package(s) (not pushed).`); + return; + } + + withGitToken(rootDir, () => { + runArgs(['git', 'push', '--no-verify', 'origin', `HEAD:refs/heads/${headRef}`], { cwd: rootDir }); + }); + log.success(`🐸 Pushed dependency bump files for ${changes.size} package(s) to ${headRef}.`); + if (!process.env.BUMPY_GH_TOKEN && process.env.GITHUB_REPOSITORY) { + log.warn( + 'BUMPY_GH_TOKEN is not set — CI will not re-run on the new commit, so required checks may stay pending.\n' + + ' For Dependabot PRs, add it under Settings → Secrets → Dependabot. Run `bumpy ci setup` for help.', + ); + } +} + // ---- ci comment ---- interface CommentOptions { diff --git a/packages/bumpy/src/commands/generate.ts b/packages/bumpy/src/commands/generate.ts index 6009c5c..2c03c1f 100644 --- a/packages/bumpy/src/commands/generate.ts +++ b/packages/bumpy/src/commands/generate.ts @@ -7,13 +7,20 @@ import { writeBumpFile } from '../core/bump-file.ts'; import { getBumpyDir } from '../core/config.ts'; import { ensureDir } from '../utils/fs.ts'; import { slugify, randomName } from '../utils/names.ts'; -import { getBranchCommits, getFilesChangedInCommit } from '../core/git.ts'; +import { getBaseCompareRef, getBranchCommits, getCurrentBranch, getFilesChangedInCommit } from '../core/git.ts'; +import { + detectDependencyChanges, + syncDependencyBumpFiles, + depBumpFileId, + formatDependencySummary, +} from '../core/dep-bump-files.ts'; import type { BumpType, BumpTypeWithNone, BumpyConfig, BumpFileRelease, WorkspacePackage } from '../types.ts'; interface GenerateOptions { from?: string; // git ref to start from (default: branch base) dryRun?: boolean; name?: string; + deps?: boolean; // generate from dependency changes instead of commits } interface ConventionalCommit { @@ -42,6 +49,11 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P const config = await loadConfig(rootDir); const packages = await discoverPackages(rootDir, config); + if (opts.deps) { + await generateDependencyBumpFiles(rootDir, config, packages, opts); + return; + } + // Get commits — either from explicit ref or from branch divergence point let commits: { hash: string; subject: string; body: string }[]; @@ -175,6 +187,43 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P } } +/** + * `generate --deps`: one patch bump file per package whose release-relevant dependencies + * changed vs the base branch. Named after the branch (or --name) so re-running updates + * the same files. `bumpy ci deps` is the CI equivalent that also commits + pushes. + */ +async function generateDependencyBumpFiles( + rootDir: string, + config: BumpyConfig, + packages: Map, + opts: GenerateOptions, +): Promise { + const suffix = opts.name ?? getCurrentBranch({ cwd: rootDir }); + if (!suffix || suffix === 'HEAD') { + throw new Error('Cannot name dependency bump files from a detached HEAD — pass --name .'); + } + const baseRef = opts.from ?? getBaseCompareRef(rootDir, config.baseBranch); + log.step(`Scanning dependency changes (vs ${colorize(opts.from ?? config.baseBranch, 'cyan')})...`); + const changes = await detectDependencyChanges(rootDir, config, packages, baseRef); + + if (opts.dryRun) { + if (changes.size === 0) { + log.info('No dependency changes detected.'); + return; + } + for (const [name, pkgChanges] of changes) { + log.bold(`Would write .bumpy/${depBumpFileId(suffix, name)}.md (${name}: patch)`); + for (const line of formatDependencySummary(pkgChanges).split('\n')) log.dim(` ${line}`); + } + return; + } + + const { written, removed } = await syncDependencyBumpFiles(rootDir, changes, suffix, baseRef); + for (const id of written) log.success(`🐸 Wrote bump file: .bumpy/${id}.md`); + for (const id of removed) log.dim(` Removed stale bump file: .bumpy/${id}.md`); + if (written.length === 0 && removed.length === 0) log.info('No dependency changes detected.'); +} + /** Merge a bump into the release map, keeping the highest bump level */ function mergeRelease( releaseMap: Map, diff --git a/packages/bumpy/src/core/dep-bump-files.ts b/packages/bumpy/src/core/dep-bump-files.ts new file mode 100644 index 0000000..e944c21 --- /dev/null +++ b/packages/bumpy/src/core/dep-bump-files.ts @@ -0,0 +1,202 @@ +import { readdir, unlink } from 'node:fs/promises'; +import { relative, resolve } from 'node:path'; +import { getBumpyDir, matchGlob } from './config.ts'; +import { writeBumpFile } from './bump-file.ts'; +import { readFileAtRef } from './git.ts'; +import { catalogYamlFile, detectPackageManager, parseCatalogs, resolveCatalogDep } from '../utils/package-manager.ts'; +import type { CatalogMap } from '../utils/package-manager.ts'; +import { ensureDir, exists, readText } from '../utils/fs.ts'; +import { slugify } from '../utils/names.ts'; +import { DEP_TYPES } from '../types.ts'; +import type { BumpyConfig, DepType, WorkspacePackage } from '../types.ts'; + +/** + * Auto-generated bump files for dependency updates (Dependabot, Renovate, …). + * + * Modeled on the-guild-org/changesets-dependencies-action: diff each managed package's + * `package.json` dependency fields against the base, and write one patch bump file per + * affected package whose summary lists the changed deps. Files are named + * deterministically (`deps--.md`) so re-running regenerates them in place + * and removes ones that no longer apply. + */ + +export interface DependencyChange { + name: string; + depType: DepType; + kind: 'added' | 'updated' | 'removed'; + /** Range before (absent for `added`) — catalog refs resolved to the catalog's range */ + from?: string; + /** Range after (absent for `removed`) — catalog refs resolved to the catalog's range */ + to?: string; +} + +/** Bump file id prefix for all auto-generated dependency bump files */ +export const DEP_BUMP_FILE_PREFIX = 'deps-'; + +/** File id for a package's generated dependency bump file, e.g. `deps-pr42-myorg-core` */ +export function depBumpFileId(suffix: string, pkgName: string): string { + return `${DEP_BUMP_FILE_PREFIX}${slugify(suffix)}-${slugify(pkgName)}`; +} + +/** + * The dependency fields whose changes should produce a release for this package: the + * ones that affect what consumers install (everything not in `ignoredPackageJsonFields`), + * plus `devDependencies` entries matching the package's `releaseTriggeringDevDeps`. + */ +function isReleaseRelevantDep(depType: DepType, depName: string, pkg: WorkspacePackage, ignored: Set): boolean { + if (!ignored.has(depType)) return true; + if (depType !== 'devDependencies') return false; + return (pkg.bumpy?.releaseTriggeringDevDeps ?? []).some((pattern) => matchGlob(depName, pattern)); +} + +/** Internal workspace deps are handled by bumpy's own cascade/propagation — not "dependency updates" */ +function isWorkspaceRange(range: string | undefined): boolean { + return !!range && range.startsWith('workspace:'); +} + +function resolveRange(depName: string, range: string | undefined, catalogs: CatalogMap): string | undefined { + if (range === undefined) return undefined; + return resolveCatalogDep(depName, range, catalogs) ?? range; +} + +async function loadCatalogsAt(rootDir: string, ref: string | null): Promise { + const pm = await detectPackageManager(rootDir); + const yamlFile = catalogYamlFile(pm); + if (ref) { + return parseCatalogs( + yamlFile ? readFileAtRef(rootDir, ref, yamlFile) : null, + readFileAtRef(rootDir, ref, 'package.json'), + ); + } + const read = async (file: string) => + (await exists(resolve(rootDir, file))) ? readText(resolve(rootDir, file)) : null; + return parseCatalogs(yamlFile ? await read(yamlFile) : null, await read('package.json')); +} + +/** + * Diff the release-relevant dependencies of every managed package between `baseRef` and + * the working tree. Packages that are new on this branch, or opt out of direct bumps + * (`directBump: false`), are skipped. Catalog refs (`catalog:`) are resolved on both + * sides, so a catalog-only update is reported against every package that uses it. + */ +export async function detectDependencyChanges( + rootDir: string, + config: BumpyConfig, + packages: Map, + baseRef: string, +): Promise> { + const ignored = new Set(config.ignoredPackageJsonFields ?? ['devDependencies']); + const [beforeCatalogs, afterCatalogs] = await Promise.all([ + loadCatalogsAt(rootDir, baseRef), + loadCatalogsAt(rootDir, null), + ]); + + const result = new Map(); + for (const [name, pkg] of packages) { + if (pkg.bumpy?.directBump === false) continue; + + const pkgRelDir = relative(rootDir, pkg.dir); + const relPath = pkgRelDir ? `${pkgRelDir}/package.json` : 'package.json'; + const beforeRaw = readFileAtRef(rootDir, baseRef, relPath); + if (beforeRaw == null) continue; // new package — not a dependency update + let before: Record; + try { + before = JSON.parse(beforeRaw); + } catch { + continue; + } + + const changes: DependencyChange[] = []; + for (const depType of DEP_TYPES) { + const beforeDeps = (before[depType] ?? {}) as Record; + const afterDeps = pkg[depType] ?? {}; + const depNames = [...new Set([...Object.keys(beforeDeps), ...Object.keys(afterDeps)])].sort(); + for (const depName of depNames) { + if (!isReleaseRelevantDep(depType, depName, pkg, ignored)) continue; + const rawFrom = beforeDeps[depName]; + const rawTo = afterDeps[depName]; + if (isWorkspaceRange(rawFrom) || isWorkspaceRange(rawTo)) continue; + const from = resolveRange(depName, rawFrom, beforeCatalogs); + const to = resolveRange(depName, rawTo, afterCatalogs); + if (from === to) continue; + const kind = from === undefined ? 'added' : to === undefined ? 'removed' : 'updated'; + changes.push({ name: depName, depType, kind, from, to }); + } + } + if (changes.length > 0) result.set(name, changes); + } + return result; +} + +/** Best-effort `x.y.z` from a range like `^1.2` or `~1.2.3-beta.1`, for the npm link */ +function coerceVersion(range: string): string | null { + const match = range.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?(-[0-9A-Za-z.-]+)?/); + if (!match) return null; + return `${match[1]}.${match[2] ?? 0}.${match[3] ?? 0}${match[4] ?? ''}`; +} + +function formatDep(name: string, range: string): string { + const label = `\`${name}@${range}\``; + // Only link registry ranges — not git/file/link/npm-alias specifiers + const version = range.includes(':') || range.includes('/') ? null : coerceVersion(range); + return version ? `[${label} ↗︎](https://www.npmjs.com/package/${name}/v/${version})` : label; +} + +/** One changelog line per changed dependency (same wording as changesets-dependencies-action) */ +export function formatDependencyChange(change: DependencyChange): string { + switch (change.kind) { + case 'added': + return `Added dependency ${formatDep(change.name, change.to!)} (to \`${change.depType}\`)`; + case 'updated': + return `Updated dependency ${formatDep(change.name, change.to!)} (from \`${change.from}\`, in \`${change.depType}\`)`; + case 'removed': + return `Removed dependency ${formatDep(change.name, change.from!)} (from \`${change.depType}\`)`; + } +} + +export function formatDependencySummary(changes: DependencyChange[]): string { + return ['Dependency updates:', '', ...changes.map((c) => `- ${formatDependencyChange(c)}`)].join('\n'); +} + +export interface SyncDependencyBumpFilesResult { + /** Bump file ids written (created or rewritten) */ + written: string[]; + /** Bump file ids removed because their package no longer has dependency changes */ + removed: string[]; +} + +/** + * Write one patch bump file per package with dependency changes, and delete previously + * generated files (same `suffix`) for packages that no longer have any. Only files added + * on this branch (absent at `baseRef`) are ever deleted — a prefix match alone could + * also hit an already-merged file from another branch. + */ +export async function syncDependencyBumpFiles( + rootDir: string, + changes: Map, + suffix: string, + baseRef: string, +): Promise { + const bumpyDir = getBumpyDir(rootDir); + await ensureDir(bumpyDir); + + const written: string[] = []; + for (const [pkgName, pkgChanges] of changes) { + const id = depBumpFileId(suffix, pkgName); + await writeBumpFile(rootDir, id, [{ name: pkgName, type: 'patch' }], formatDependencySummary(pkgChanges)); + written.push(id); + } + + const stalePrefix = `${DEP_BUMP_FILE_PREFIX}${slugify(suffix)}-`; + const removed: string[] = []; + for (const file of await readdir(bumpyDir)) { + if (!file.endsWith('.md') || !file.startsWith(stalePrefix)) continue; + const id = file.slice(0, -'.md'.length); + if (written.includes(id)) continue; + if (readFileAtRef(rootDir, baseRef, relative(rootDir, resolve(bumpyDir, file))) != null) continue; + await unlink(resolve(bumpyDir, file)); + removed.push(id); + } + + return { written, removed }; +} diff --git a/packages/bumpy/test/core/dep-bump-files.test.ts b/packages/bumpy/test/core/dep-bump-files.test.ts new file mode 100644 index 0000000..573b7ef --- /dev/null +++ b/packages/bumpy/test/core/dep-bump-files.test.ts @@ -0,0 +1,192 @@ +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { resolve } from 'node:path'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { + detectDependencyChanges, + formatDependencyChange, + syncDependencyBumpFiles, + type DependencyChange, +} from '../../src/core/dep-bump-files.ts'; +import { discoverWorkspace } from '../../src/core/workspace.ts'; +import { getBaseCompareRef } from '../../src/core/git.ts'; +import { loadConfig } from '../../src/core/config.ts'; +import { createTempGitRepo, cleanupTempDir, gitInDir } from '../helpers.ts'; + +describe('formatDependencyChange', () => { + test('updated dep links to the npm version', () => { + const change: DependencyChange = { + name: '@scope/lib', + depType: 'dependencies', + kind: 'updated', + from: '^1.2.0', + to: '^1.3', + }; + expect(formatDependencyChange(change)).toBe( + 'Updated dependency [`@scope/lib@^1.3` ↗︎](https://www.npmjs.com/package/@scope/lib/v/1.3.0) (from `^1.2.0`, in `dependencies`)', + ); + }); + + test('added / removed wording', () => { + expect(formatDependencyChange({ name: 'a', depType: 'peerDependencies', kind: 'added', to: '2.0.0' })).toBe( + 'Added dependency [`a@2.0.0` ↗︎](https://www.npmjs.com/package/a/v/2.0.0) (to `peerDependencies`)', + ); + expect(formatDependencyChange({ name: 'a', depType: 'dependencies', kind: 'removed', from: '^1.0.0' })).toBe( + 'Removed dependency [`a@^1.0.0` ↗︎](https://www.npmjs.com/package/a/v/1.0.0) (from `dependencies`)', + ); + }); + + test('non-registry specifiers are not linked', () => { + expect( + formatDependencyChange({ name: 'a', depType: 'dependencies', kind: 'added', to: 'github:org/a#v1.0.0' }), + ).toBe('Added dependency `a@github:org/a#v1.0.0` (to `dependencies`)'); + }); +}); + +describe('dependency bump files (git integration)', () => { + let tmpDir: string; + const teardown: Array<() => Promise> = []; + + beforeEach(async () => { + tmpDir = await createTempGitRepo(); + gitInDir(['branch', '-M', 'main'], tmpDir); + gitInDir(['config', 'user.email', 'test@example.com'], tmpDir); + gitInDir(['config', 'user.name', 'Test'], tmpDir); + }); + + afterEach(async () => { + for (const fn of teardown) await fn(); + teardown.length = 0; + await cleanupTempDir(tmpDir); + }); + + async function writeJson(path: string, data: unknown): Promise { + await mkdir(resolve(tmpDir, path, '..'), { recursive: true }); + await writeFile(resolve(tmpDir, path), `${JSON.stringify(data, null, 2)}\n`); + } + + /** Commit `pkgs` (path → package.json) on main, push to a bare origin, then branch. */ + async function setup(pkgs: Record>, rootExtra: Record = {}) { + await writeJson('package.json', { name: 'root', private: true, workspaces: ['packages/*'], ...rootExtra }); + for (const [dir, pkg] of Object.entries(pkgs)) await writeJson(`packages/${dir}/package.json`, pkg); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init'], tmpDir); + const { mkdtemp, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const remote = await mkdtemp(resolve(tmpdir(), 'bumpy-remote-')); + gitInDir(['init', '--bare'], remote); + gitInDir(['remote', 'add', 'origin', remote], tmpDir); + gitInDir(['push', '-u', 'origin', 'main'], tmpDir); + teardown.push(() => rm(remote, { recursive: true, force: true })); + gitInDir(['checkout', '-b', 'dependabot/npm_and_yarn/lodash'], tmpDir); + } + + async function detect() { + const config = await loadConfig(tmpDir); + const { packages } = await discoverWorkspace(tmpDir, config); + const baseRef = getBaseCompareRef(tmpDir, config.baseBranch); + return { changes: await detectDependencyChanges(tmpDir, config, packages, baseRef), baseRef }; + } + + test('detects runtime/peer changes, ignores dev-only and workspace deps', async () => { + await setup({ + core: { name: 'core', version: '1.0.0' }, + app: { + name: 'app', + version: '1.0.0', + dependencies: { lodash: '^4.0.0', core: 'workspace:^1.0.0', old: '^1.0.0' }, + devDependencies: { vitest: '^1.0.0' }, + }, + }); + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + dependencies: { lodash: '^4.17.21', core: 'workspace:^1.1.0' }, + peerDependencies: { react: '^19.0.0' }, + devDependencies: { vitest: '^2.0.0' }, + }); + + const { changes } = await detect(); + expect([...changes.keys()]).toEqual(['app']); + expect(changes.get('app')).toEqual([ + { name: 'lodash', depType: 'dependencies', kind: 'updated', from: '^4.0.0', to: '^4.17.21' }, + { name: 'old', depType: 'dependencies', kind: 'removed', from: '^1.0.0', to: undefined }, + { name: 'react', depType: 'peerDependencies', kind: 'added', from: undefined, to: '^19.0.0' }, + ]); + }); + + test('releaseTriggeringDevDeps devDependencies count', async () => { + await setup({ + app: { + name: 'app', + version: '1.0.0', + devDependencies: { nanoid: '^4.0.0', vitest: '^1.0.0' }, + bumpy: { releaseTriggeringDevDeps: ['nanoid'] }, + }, + }); + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + devDependencies: { nanoid: '^5.0.0', vitest: '^2.0.0' }, + bumpy: { releaseTriggeringDevDeps: ['nanoid'] }, + }); + const { changes } = await detect(); + expect(changes.get('app')?.map((c) => c.name)).toEqual(['nanoid']); + }); + + test('catalog updates are attributed to packages that use the catalog entry', async () => { + await setup( + { + app: { name: 'app', version: '1.0.0', dependencies: { lodash: 'catalog:' } }, + other: { name: 'other', version: '1.0.0', dependencies: { zod: '^3.0.0' } }, + }, + { workspaces: { packages: ['packages/*'], catalog: { lodash: '^4.0.0' } } }, + ); + await writeJson('package.json', { + name: 'root', + private: true, + workspaces: { packages: ['packages/*'], catalog: { lodash: '^4.17.21' } }, + }); + const { changes } = await detect(); + expect([...changes.keys()]).toEqual(['app']); + expect(changes.get('app')![0]).toMatchObject({ name: 'lodash', from: '^4.0.0', to: '^4.17.21' }); + }); + + test('writes one patch bump file per package and removes stale ones on re-run', async () => { + await setup({ + a: { name: '@org/a', version: '1.0.0', dependencies: { lodash: '^4.0.0' } }, + b: { name: '@org/b', version: '1.0.0', dependencies: { lodash: '^4.0.0' } }, + }); + // A pre-existing, merged file with a colliding prefix must never be deleted + await mkdir(resolve(tmpDir, '.bumpy'), { recursive: true }); + await writeFile(resolve(tmpDir, '.bumpy/deps-pr7-org-old.md'), '---\n"@org/a": patch\n---\n\nold\n'); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'merged file'], tmpDir); + gitInDir(['push', 'origin', 'HEAD:main'], tmpDir); + + for (const dir of ['a', 'b']) { + await writeJson(`packages/${dir}/package.json`, { + name: `@org/${dir}`, + version: '1.0.0', + dependencies: { lodash: '^4.17.21' }, + }); + } + let { changes, baseRef } = await detect(); + let result = await syncDependencyBumpFiles(tmpDir, changes, 'pr7', baseRef); + expect(result.written.sort()).toEqual(['deps-pr7-org-a', 'deps-pr7-org-b']); + const content = await readFile(resolve(tmpDir, '.bumpy/deps-pr7-org-a.md'), 'utf-8'); + expect(content).toContain('"@org/a": patch'); + expect(content).toContain('Updated dependency [`lodash@^4.17.21` ↗︎]'); + + // Revert b's change — its file goes away, a's stays, the merged file is untouched + await writeJson('packages/b/package.json', { + name: '@org/b', + version: '1.0.0', + dependencies: { lodash: '^4.0.0' }, + }); + ({ changes, baseRef } = await detect()); + result = await syncDependencyBumpFiles(tmpDir, changes, 'pr7', baseRef); + expect(result.written).toEqual(['deps-pr7-org-a']); + expect(result.removed).toEqual(['deps-pr7-org-b']); + expect((await readdir(resolve(tmpDir, '.bumpy'))).sort()).toEqual(['deps-pr7-org-a.md', 'deps-pr7-org-old.md']); + }); +}); From 3400fcda4ad21807c8a4bca8e9104f5a65610386 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 5 Oct 2026 10:52:33 -0700 Subject: [PATCH 2/3] fix(ci deps): cover workspace ranges + directBump:false, collision-safe ids, only delete marked files --- docs/github-actions.md | 5 +- packages/bumpy/src/commands/ci.ts | 33 +++- packages/bumpy/src/commands/generate.ts | 31 ++-- packages/bumpy/src/core/bump-file.ts | 10 +- packages/bumpy/src/core/dep-bump-files.ts | 152 ++++++++++++++---- .../bumpy/test/core/dep-bump-files.test.ts | 79 ++++++++- 6 files changed, 250 insertions(+), 60 deletions(-) diff --git a/docs/github-actions.md b/docs/github-actions.md index 5b59cc5..4bc3bd0 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -111,9 +111,10 @@ Point `workflows: [...]` at the **name** of whatever runs your check (your exist A dev-only update (`devDependencies`) never needs a bump file — see [change detection](configuration.md#change-detection-and-packagejson-fields). An update to `dependencies`, `peerDependencies` or `optionalDependencies` does, since it changes what your consumers install. Rather than adding those by hand, `bumpy ci deps` writes them for you (the bumpy equivalent of [changesets-dependencies-action](https://github.com/the-guild-org/changesets-dependencies-action)): -- diffs each managed package's dependencies against the base branch (catalog updates included; `workspace:` deps skipped — bumpy's own propagation handles those; `devDependencies` only if listed in [`releaseTriggeringDevDeps`](configuration.md#release-triggering-devdependencies)) +- diffs each managed package's dependencies against the base branch — the same changes `bumpy check` requires a bump file for (catalog updates included; `devDependencies` only if listed in [`releaseTriggeringDevDeps`](configuration.md#release-triggering-devdependencies)) - writes one **patch** bump file per affected package, `.bumpy/deps-pr-.md`, listing each added / updated / removed dependency with a link to npm -- commits and pushes them to the PR branch — re-running (e.g. after Dependabot rebases) rewrites the files and deletes ones that no longer apply +- for a [`directBump: false`](configuration.md#directbump-false--packages-that-only-follow) package, puts the bump on a fixed-group member instead (and fails if there isn't one) +- commits and pushes them to the PR branch — re-running (e.g. after Dependabot rebases) rewrites the files and deletes ones that no longer apply. Generated files carry a marker comment; files without it are never touched Run it before `ci check`, in the same job, so the check sees the new files: diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 4788833..f8191ae 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -5,7 +5,12 @@ import { discoverWorkspace } from '../core/workspace.ts'; import { DependencyGraph } from '../core/dep-graph.ts'; import { readBumpFiles, filterBranchBumpFiles, recoverDeletedBumpFiles } from '../core/bump-file.ts'; import { getBaseCompareRef, getChangedFiles, getCurrentBranch, withGitToken } from '../core/git.ts'; -import { detectDependencyChanges, syncDependencyBumpFiles } from '../core/dep-bump-files.ts'; +import { + detectDependencyChanges, + formatUncoverableError, + planDependencyBumpFiles, + syncDependencyBumpFiles, +} from '../core/dep-bump-files.ts'; import { assembleReleasePlan } from '../core/release-plan.ts'; import { channelNames, @@ -328,7 +333,9 @@ export async function ciDepsCommand(rootDir: string, opts: DepsOptions): Promise const { packages } = await discoverWorkspace(rootDir, config); const baseRef = getBaseCompareRef(rootDir, config.baseBranch); const changes = await detectDependencyChanges(rootDir, config, packages, baseRef); - const { written, removed } = await syncDependencyBumpFiles(rootDir, changes, `pr${prNumber}`, baseRef); + const suffix = `pr${prNumber}`; + const plan = planDependencyBumpFiles(changes, packages, config, suffix); + const { written, removed } = await syncDependencyBumpFiles(rootDir, plan, suffix, baseRef); for (const id of written) log.dim(` .bumpy/${id}.md`); for (const id of removed) log.dim(` removed .bumpy/${id}.md`); @@ -337,9 +344,25 @@ export async function ciDepsCommand(rootDir: string, opts: DepsOptions): Promise if (!dirty) { log.info(changes.size === 0 ? 'No dependency changes detected.' : 'Dependency bump files are up to date.'); writeGitHubOutput('changed', 'false'); - return; + } else { + commitAndPushDepBumpFiles(rootDir, config, bumpyRelDir, headRef, plan.files.length, opts); } + // Push what we could, then fail on what we couldn't — the PR still needs a manual bump file. + if (plan.uncoverable.length > 0) { + log.error(formatUncoverableError(plan.uncoverable)); + process.exit(1); + } +} + +function commitAndPushDepBumpFiles( + rootDir: string, + config: BumpyConfig, + bumpyRelDir: string, + headRef: string, + fileCount: number, + opts: DepsOptions, +): void { ensureGitIdentity(rootDir, config); runArgs(['git', 'add', '-A', '--', bumpyRelDir], { cwd: rootDir }); runArgs(['git', 'commit', '--no-verify', '-F', '-'], { @@ -349,14 +372,14 @@ export async function ciDepsCommand(rootDir: string, opts: DepsOptions): Promise writeGitHubOutput('changed', 'true'); if (opts.noPush) { - log.success(`🐸 Committed dependency bump files for ${changes.size} package(s) (not pushed).`); + log.success(`🐸 Committed ${fileCount} dependency bump file(s) (not pushed).`); return; } withGitToken(rootDir, () => { runArgs(['git', 'push', '--no-verify', 'origin', `HEAD:refs/heads/${headRef}`], { cwd: rootDir }); }); - log.success(`🐸 Pushed dependency bump files for ${changes.size} package(s) to ${headRef}.`); + log.success(`🐸 Pushed ${fileCount} dependency bump file(s) to ${headRef}.`); if (!process.env.BUMPY_GH_TOKEN && process.env.GITHUB_REPOSITORY) { log.warn( 'BUMPY_GH_TOKEN is not set — CI will not re-run on the new commit, so required checks may stay pending.\n' + diff --git a/packages/bumpy/src/commands/generate.ts b/packages/bumpy/src/commands/generate.ts index 2c03c1f..3631585 100644 --- a/packages/bumpy/src/commands/generate.ts +++ b/packages/bumpy/src/commands/generate.ts @@ -10,9 +10,9 @@ import { slugify, randomName } from '../utils/names.ts'; import { getBaseCompareRef, getBranchCommits, getCurrentBranch, getFilesChangedInCommit } from '../core/git.ts'; import { detectDependencyChanges, + planDependencyBumpFiles, syncDependencyBumpFiles, - depBumpFileId, - formatDependencySummary, + formatUncoverableError, } from '../core/dep-bump-files.ts'; import type { BumpType, BumpTypeWithNone, BumpyConfig, BumpFileRelease, WorkspacePackage } from '../types.ts'; @@ -206,22 +206,25 @@ async function generateDependencyBumpFiles( log.step(`Scanning dependency changes (vs ${colorize(opts.from ?? config.baseBranch, 'cyan')})...`); const changes = await detectDependencyChanges(rootDir, config, packages, baseRef); + const plan = planDependencyBumpFiles(changes, packages, config, suffix); + if (opts.dryRun) { - if (changes.size === 0) { - log.info('No dependency changes detected.'); - return; - } - for (const [name, pkgChanges] of changes) { - log.bold(`Would write .bumpy/${depBumpFileId(suffix, name)}.md (${name}: patch)`); - for (const line of formatDependencySummary(pkgChanges).split('\n')) log.dim(` ${line}`); + if (changes.size === 0) log.info('No dependency changes detected.'); + for (const file of plan.files) { + log.bold(`Would write .bumpy/${file.id}.md (${file.releaseName}: patch)`); + for (const line of file.summary.split('\n')) log.dim(` ${line}`); } - return; + } else { + const { written, removed } = await syncDependencyBumpFiles(rootDir, plan, suffix, baseRef); + for (const id of written) log.success(`🐸 Wrote bump file: .bumpy/${id}.md`); + for (const id of removed) log.dim(` Removed stale bump file: .bumpy/${id}.md`); + if (changes.size === 0 && removed.length === 0) log.info('No dependency changes detected.'); } - const { written, removed } = await syncDependencyBumpFiles(rootDir, changes, suffix, baseRef); - for (const id of written) log.success(`🐸 Wrote bump file: .bumpy/${id}.md`); - for (const id of removed) log.dim(` Removed stale bump file: .bumpy/${id}.md`); - if (written.length === 0 && removed.length === 0) log.info('No dependency changes detected.'); + if (plan.uncoverable.length > 0) { + log.error(formatUncoverableError(plan.uncoverable)); + process.exit(1); + } } /** Merge a bump into the release map, keeping the highest bump level */ diff --git a/packages/bumpy/src/core/bump-file.ts b/packages/bumpy/src/core/bump-file.ts index c1b670a..4d3b041 100644 --- a/packages/bumpy/src/core/bump-file.ts +++ b/packages/bumpy/src/core/bump-file.ts @@ -235,6 +235,8 @@ export async function writeBumpFile( filename: string, releases: BumpFileRelease[], summary: string, + /** Optional YAML comment line(s) placed at the top of the frontmatter (each prefixed `# `) */ + frontmatterComment?: string, ): Promise { const dir = getBumpyDir(rootDir); const filePath = resolve(dir, `${filename}.md`); @@ -250,7 +252,13 @@ export async function writeBumpFile( } const yamlStr = yaml.dump(frontmatter, { lineWidth: -1, quotingType: '"' }).trim(); - const content = `---\n${yamlStr}\n---\n\n${summary}\n`; + const comment = frontmatterComment + ? frontmatterComment + .split('\n') + .map((line) => `# ${line}\n`) + .join('') + : ''; + const content = `---\n${comment}${yamlStr}\n---\n\n${summary}\n`; await writeText(filePath, content); return filePath; } diff --git a/packages/bumpy/src/core/dep-bump-files.ts b/packages/bumpy/src/core/dep-bump-files.ts index e944c21..ad9e8c1 100644 --- a/packages/bumpy/src/core/dep-bump-files.ts +++ b/packages/bumpy/src/core/dep-bump-files.ts @@ -1,6 +1,7 @@ -import { readdir, unlink } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { readdir, readFile, unlink } from 'node:fs/promises'; import { relative, resolve } from 'node:path'; -import { getBumpyDir, matchGlob } from './config.ts'; +import { getBumpyDir, matchGlob, resolveFixedGroups } from './config.ts'; import { writeBumpFile } from './bump-file.ts'; import { readFileAtRef } from './git.ts'; import { catalogYamlFile, detectPackageManager, parseCatalogs, resolveCatalogDep } from '../utils/package-manager.ts'; @@ -16,8 +17,9 @@ import type { BumpyConfig, DepType, WorkspacePackage } from '../types.ts'; * Modeled on the-guild-org/changesets-dependencies-action: diff each managed package's * `package.json` dependency fields against the base, and write one patch bump file per * affected package whose summary lists the changed deps. Files are named - * deterministically (`deps--.md`) so re-running regenerates them in place - * and removes ones that no longer apply. + * deterministically (`deps--.md`) and carry a marker comment, so re-running + * regenerates them in place and removes ones that no longer apply — and never touches + * bump files it didn't generate. */ export interface DependencyChange { @@ -33,9 +35,23 @@ export interface DependencyChange { /** Bump file id prefix for all auto-generated dependency bump files */ export const DEP_BUMP_FILE_PREFIX = 'deps-'; -/** File id for a package's generated dependency bump file, e.g. `deps-pr42-myorg-core` */ -export function depBumpFileId(suffix: string, pkgName: string): string { - return `${DEP_BUMP_FILE_PREFIX}${slugify(suffix)}-${slugify(pkgName)}`; +/** Frontmatter comment marking a bump file as generated — only marked files are ever deleted */ +export const DEP_BUMP_FILE_MARKER = 'generated by bumpy from dependency changes — regenerated on each run'; + +/** + * File id for a package's generated dependency bump file, e.g. `deps-pr42-myorg-core`. + * `slugify` is lossy (`@org/a` and `org-a` both → `org-a`), so when a package's slug + * collides with another managed package's, a short hash of the full name is appended. + * Collisions are checked against all managed packages, so ids stay stable across runs. + */ +export function depBumpFileId(suffix: string, pkgName: string, allPackageNames: Iterable = []): string { + const slug = slugify(pkgName); + let collides = false; + for (const other of allPackageNames) { + if (other !== pkgName && slugify(other) === slug) collides = true; + } + const unique = collides ? `${slug}-${createHash('sha256').update(pkgName).digest('hex').slice(0, 8)}` : slug; + return `${DEP_BUMP_FILE_PREFIX}${slugify(suffix)}-${unique}`; } /** @@ -49,11 +65,6 @@ function isReleaseRelevantDep(depType: DepType, depName: string, pkg: WorkspaceP return (pkg.bumpy?.releaseTriggeringDevDeps ?? []).some((pattern) => matchGlob(depName, pattern)); } -/** Internal workspace deps are handled by bumpy's own cascade/propagation — not "dependency updates" */ -function isWorkspaceRange(range: string | undefined): boolean { - return !!range && range.startsWith('workspace:'); -} - function resolveRange(depName: string, range: string | undefined, catalogs: CatalogMap): string | undefined { if (range === undefined) return undefined; return resolveCatalogDep(depName, range, catalogs) ?? range; @@ -75,9 +86,10 @@ async function loadCatalogsAt(rootDir: string, ref: string | null): Promise(); for (const [name, pkg] of packages) { - if (pkg.bumpy?.directBump === false) continue; - const pkgRelDir = relative(rootDir, pkg.dir); const relPath = pkgRelDir ? `${pkgRelDir}/package.json` : 'package.json'; const beforeRaw = readFileAtRef(rootDir, baseRef, relPath); @@ -115,7 +125,6 @@ export async function detectDependencyChanges( if (!isReleaseRelevantDep(depType, depName, pkg, ignored)) continue; const rawFrom = beforeDeps[depName]; const rawTo = afterDeps[depName]; - if (isWorkspaceRange(rawFrom) || isWorkspaceRange(rawTo)) continue; const from = resolveRange(depName, rawFrom, beforeCatalogs); const to = resolveRange(depName, rawTo, afterCatalogs); if (from === to) continue; @@ -154,8 +163,70 @@ export function formatDependencyChange(change: DependencyChange): string { } } -export function formatDependencySummary(changes: DependencyChange[]): string { - return ['Dependency updates:', '', ...changes.map((c) => `- ${formatDependencyChange(c)}`)].join('\n'); +export function formatDependencySummary(changes: DependencyChange[], viaPackage?: string): string { + const heading = viaPackage ? `Dependency updates in \`${viaPackage}\`:` : 'Dependency updates:'; + return [heading, '', ...changes.map((c) => `- ${formatDependencyChange(c)}`)].join('\n'); +} + +export interface PlannedDependencyBumpFile { + id: string; + /** Package whose dependencies changed */ + pkgName: string; + /** Package that receives the patch bump — differs for `directBump: false` packages */ + releaseName: string; + summary: string; +} + +export interface DependencyBumpFilePlan { + files: PlannedDependencyBumpFile[]; + /** `directBump: false` packages with dependency changes but no fixed-group member to carry the bump */ + uncoverable: string[]; +} + +/** + * Turn detected changes into bump files. A `directBump: false` package can't be bumped + * itself — its changes ship by bumping another member of its fixed group (see + * `resolveDirectBumpCoverage` in `bumpy check`), so the bump goes on the first bumpable + * member. With no such member it's reported as `uncoverable`. + */ +export function planDependencyBumpFiles( + changes: Map, + packages: Map, + config: BumpyConfig, + suffix: string, +): DependencyBumpFilePlan { + const fixedGroups = resolveFixedGroups(config, packages.keys()); + const files: PlannedDependencyBumpFile[] = []; + const uncoverable: string[] = []; + + for (const [pkgName, pkgChanges] of changes) { + let releaseName = pkgName; + if (packages.get(pkgName)?.bumpy?.directBump === false) { + const group = fixedGroups.find((members) => members.includes(pkgName)) ?? []; + const driver = group.filter((m) => m !== pkgName && packages.get(m)?.bumpy?.directBump !== false).sort()[0]; + if (!driver) { + uncoverable.push(pkgName); + continue; + } + releaseName = driver; + } + files.push({ + id: depBumpFileId(suffix, pkgName, packages.keys()), + pkgName, + releaseName, + summary: formatDependencySummary(pkgChanges, releaseName === pkgName ? undefined : pkgName), + }); + } + return { files, uncoverable }; +} + +/** Error text for `directBump: false` packages whose dependency changes can't be covered */ +export function formatUncoverableError(names: string[]): string { + return ( + `Dependency changes in ${names.map((n) => `"${n}"`).join(', ')} can't be covered automatically — ` + + '`directBump: false` packages only release via a fixed-group member or propagation, and none is configured.\n' + + ' Add the package to a fixed group with a bumpable member, or add a bump file for its cascade source by hand.' + ); } export interface SyncDependencyBumpFilesResult { @@ -165,15 +236,27 @@ export interface SyncDependencyBumpFilesResult { removed: string[]; } +/** Whether an existing bump file was written by `syncDependencyBumpFiles` */ +async function isGeneratedDepBumpFile(filePath: string): Promise { + try { + const content = await readFile(filePath, 'utf-8'); + const frontmatter = content.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''; + return frontmatter.split('\n').some((line) => line.trim() === `# ${DEP_BUMP_FILE_MARKER}`); + } catch { + return false; + } +} + /** - * Write one patch bump file per package with dependency changes, and delete previously - * generated files (same `suffix`) for packages that no longer have any. Only files added - * on this branch (absent at `baseRef`) are ever deleted — a prefix match alone could - * also hit an already-merged file from another branch. + * Write the planned bump files, and delete previously generated files (same `suffix`) + * for packages that no longer have changes. A file is only deleted when it is + * positively ours: same prefix, carries the generated marker, and was added on this + * branch (absent at `baseRef`) — so hand-written files and already-merged files from + * other branches are never touched. */ export async function syncDependencyBumpFiles( rootDir: string, - changes: Map, + plan: DependencyBumpFilePlan, suffix: string, baseRef: string, ): Promise { @@ -181,10 +264,15 @@ export async function syncDependencyBumpFiles( await ensureDir(bumpyDir); const written: string[] = []; - for (const [pkgName, pkgChanges] of changes) { - const id = depBumpFileId(suffix, pkgName); - await writeBumpFile(rootDir, id, [{ name: pkgName, type: 'patch' }], formatDependencySummary(pkgChanges)); - written.push(id); + for (const file of plan.files) { + await writeBumpFile( + rootDir, + file.id, + [{ name: file.releaseName, type: 'patch' }], + file.summary, + DEP_BUMP_FILE_MARKER, + ); + written.push(file.id); } const stalePrefix = `${DEP_BUMP_FILE_PREFIX}${slugify(suffix)}-`; @@ -193,8 +281,10 @@ export async function syncDependencyBumpFiles( if (!file.endsWith('.md') || !file.startsWith(stalePrefix)) continue; const id = file.slice(0, -'.md'.length); if (written.includes(id)) continue; - if (readFileAtRef(rootDir, baseRef, relative(rootDir, resolve(bumpyDir, file))) != null) continue; - await unlink(resolve(bumpyDir, file)); + const filePath = resolve(bumpyDir, file); + if (!(await isGeneratedDepBumpFile(filePath))) continue; + if (readFileAtRef(rootDir, baseRef, relative(rootDir, filePath)) != null) continue; + await unlink(filePath); removed.push(id); } diff --git a/packages/bumpy/test/core/dep-bump-files.test.ts b/packages/bumpy/test/core/dep-bump-files.test.ts index 573b7ef..692fce2 100644 --- a/packages/bumpy/test/core/dep-bump-files.test.ts +++ b/packages/bumpy/test/core/dep-bump-files.test.ts @@ -2,8 +2,10 @@ import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; import { resolve } from 'node:path'; import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { + depBumpFileId, detectDependencyChanges, formatDependencyChange, + planDependencyBumpFiles, syncDependencyBumpFiles, type DependencyChange, } from '../../src/core/dep-bump-files.ts'; @@ -42,6 +44,21 @@ describe('formatDependencyChange', () => { }); }); +describe('depBumpFileId', () => { + test('plain slug when unambiguous', () => { + expect(depBumpFileId('pr7', '@org/a', ['@org/a', '@org/b'])).toBe('deps-pr7-org-a'); + }); + + test('adds a name hash when slugs collide, so distinct packages get distinct ids', () => { + const names = ['@org/a', 'org-a']; + const a = depBumpFileId('pr7', '@org/a', names); + const b = depBumpFileId('pr7', 'org-a', names); + expect(a).toMatch(/^deps-pr7-org-a-[0-9a-f]{8}$/); + expect(b).toMatch(/^deps-pr7-org-a-[0-9a-f]{8}$/); + expect(a).not.toBe(b); + }); +}); + describe('dependency bump files (git integration)', () => { let tmpDir: string; const teardown: Array<() => Promise> = []; @@ -80,14 +97,15 @@ describe('dependency bump files (git integration)', () => { gitInDir(['checkout', '-b', 'dependabot/npm_and_yarn/lodash'], tmpDir); } - async function detect() { + async function detect(suffix = 'pr7') { const config = await loadConfig(tmpDir); const { packages } = await discoverWorkspace(tmpDir, config); const baseRef = getBaseCompareRef(tmpDir, config.baseBranch); - return { changes: await detectDependencyChanges(tmpDir, config, packages, baseRef), baseRef }; + const changes = await detectDependencyChanges(tmpDir, config, packages, baseRef); + return { changes, baseRef, plan: planDependencyBumpFiles(changes, packages, config, suffix) }; } - test('detects runtime/peer changes, ignores dev-only and workspace deps', async () => { + test('detects runtime/peer/workspace-range changes, ignores dev-only deps', async () => { await setup({ core: { name: 'core', version: '1.0.0' }, app: { @@ -108,6 +126,7 @@ describe('dependency bump files (git integration)', () => { const { changes } = await detect(); expect([...changes.keys()]).toEqual(['app']); expect(changes.get('app')).toEqual([ + { name: 'core', depType: 'dependencies', kind: 'updated', from: 'workspace:^1.0.0', to: 'workspace:^1.1.0' }, { name: 'lodash', depType: 'dependencies', kind: 'updated', from: '^4.0.0', to: '^4.17.21' }, { name: 'old', depType: 'dependencies', kind: 'removed', from: '^1.0.0', to: undefined }, { name: 'react', depType: 'peerDependencies', kind: 'added', from: undefined, to: '^19.0.0' }, @@ -170,11 +189,12 @@ describe('dependency bump files (git integration)', () => { dependencies: { lodash: '^4.17.21' }, }); } - let { changes, baseRef } = await detect(); - let result = await syncDependencyBumpFiles(tmpDir, changes, 'pr7', baseRef); + let { plan, baseRef } = await detect(); + let result = await syncDependencyBumpFiles(tmpDir, plan, 'pr7', baseRef); expect(result.written.sort()).toEqual(['deps-pr7-org-a', 'deps-pr7-org-b']); const content = await readFile(resolve(tmpDir, '.bumpy/deps-pr7-org-a.md'), 'utf-8'); expect(content).toContain('"@org/a": patch'); + expect(content).toMatch(/^---\n# generated by bumpy/); expect(content).toContain('Updated dependency [`lodash@^4.17.21` ↗︎]'); // Revert b's change — its file goes away, a's stays, the merged file is untouched @@ -183,10 +203,55 @@ describe('dependency bump files (git integration)', () => { version: '1.0.0', dependencies: { lodash: '^4.0.0' }, }); - ({ changes, baseRef } = await detect()); - result = await syncDependencyBumpFiles(tmpDir, changes, 'pr7', baseRef); + ({ plan, baseRef } = await detect()); + result = await syncDependencyBumpFiles(tmpDir, plan, 'pr7', baseRef); expect(result.written).toEqual(['deps-pr7-org-a']); expect(result.removed).toEqual(['deps-pr7-org-b']); expect((await readdir(resolve(tmpDir, '.bumpy'))).sort()).toEqual(['deps-pr7-org-a.md', 'deps-pr7-org-old.md']); }); + + test('never deletes a hand-written bump file that shares the generated prefix', async () => { + await setup({ a: { name: 'a', version: '1.0.0', dependencies: { lodash: '^4.0.0' } } }); + await mkdir(resolve(tmpDir, '.bumpy'), { recursive: true }); + // e.g. `bumpy add --name deps-pr7-hotfix` on this branch + await writeFile(resolve(tmpDir, '.bumpy/deps-pr7-hotfix.md'), '---\na: patch\n---\n\nhotfix\n'); + const { plan, baseRef } = await detect(); + const result = await syncDependencyBumpFiles(tmpDir, plan, 'pr7', baseRef); + expect(result.removed).toEqual([]); + expect(await readdir(resolve(tmpDir, '.bumpy'))).toContain('deps-pr7-hotfix.md'); + }); + + test('directBump: false packages bump a fixed-group member instead', async () => { + await setup({ + cli: { name: 'cli', version: '1.0.0' }, + bin: { name: 'cli-bin', version: '1.0.0', dependencies: { zod: '^3.0.0' }, bumpy: { directBump: false } }, + }); + await writeJson('.bumpy/_config.json', { fixed: [['cli', 'cli-bin']] }); + await writeJson('packages/bin/package.json', { + name: 'cli-bin', + version: '1.0.0', + dependencies: { zod: '^3.23.0' }, + bumpy: { directBump: false }, + }); + const { plan } = await detect(); + expect(plan.uncoverable).toEqual([]); + expect(plan.files).toHaveLength(1); + expect(plan.files[0]).toMatchObject({ id: 'deps-pr7-cli-bin', pkgName: 'cli-bin', releaseName: 'cli' }); + expect(plan.files[0]!.summary).toStartWith('Dependency updates in `cli-bin`:'); + }); + + test('directBump: false with no fixed-group driver is reported as uncoverable', async () => { + await setup({ + bin: { name: 'cli-bin', version: '1.0.0', dependencies: { zod: '^3.0.0' }, bumpy: { directBump: false } }, + }); + await writeJson('packages/bin/package.json', { + name: 'cli-bin', + version: '1.0.0', + dependencies: { zod: '^3.23.0' }, + bumpy: { directBump: false }, + }); + const { plan } = await detect(); + expect(plan.files).toEqual([]); + expect(plan.uncoverable).toEqual(['cli-bin']); + }); }); From 9c73041298221a12dd9a209f934ac8bca434db4a Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 6 Oct 2026 23:09:59 -0700 Subject: [PATCH 3/3] fix(ci deps): diff against the PR base branch; docs gate on PR author --- .bumpy/dependency-update-bump-files.md | 2 + docs/github-actions.md | 7 +- packages/bumpy/src/commands/check.ts | 13 +++- packages/bumpy/src/commands/ci.ts | 9 ++- .../bumpy/test/core/dep-bump-files.test.ts | 77 +++++++++++++++++++ 5 files changed, 99 insertions(+), 9 deletions(-) diff --git a/.bumpy/dependency-update-bump-files.md b/.bumpy/dependency-update-bump-files.md index b786192..98cf349 100644 --- a/.bumpy/dependency-update-bump-files.md +++ b/.bumpy/dependency-update-bump-files.md @@ -3,3 +3,5 @@ --- Add `bumpy ci deps` for Dependabot / Renovate PRs (modeled on changesets-dependencies-action). It diffs each package's `dependencies` / `peerDependencies` / `optionalDependencies` (plus `releaseTriggeringDevDeps`, and catalog updates) against the base branch, writes one patch bump file per affected package listing the added / updated / removed deps, and commits + pushes them to the PR branch. Re-running is idempotent and removes stale files. `bumpy generate --deps` does the same locally without committing. + +Also fix `ci check` on PRs into a channel branch: the `package.json` field diff and catalog diff compared against `baseBranch` instead of the PR's base, so dependency drift already merged into the channel was attributed to every later PR. diff --git a/docs/github-actions.md b/docs/github-actions.md index 4bc3bd0..fb93f91 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -125,7 +125,9 @@ on: pull_request jobs: deps: - if: github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]' + # gate on the PR author, not github.actor — so a maintainer's push to a bot PR + # (e.g. pinning or reverting the update) regenerates the files too + if: contains(fromJSON('["dependabot[bot]", "renovate[bot]"]'), github.event.pull_request.user.login) runs-on: ubuntu-latest permissions: contents: write # push the bump-file commit @@ -145,13 +147,14 @@ jobs: GH_TOKEN: ${{ github.token }} ``` -Then skip bumpy's regular PR check for those actors (`if: github.actor != 'dependabot[bot]' && ...`), or leave it — it will pass on the re-run triggered by the pushed commit. +Then skip bumpy's regular PR check for those PRs (the same condition, negated), or leave it — it will pass on the re-run triggered by the pushed commit. The re-run of this workflow finds the files up to date and commits nothing, so it doesn't loop. Things to know: - **Use `BUMPY_GH_TOKEN` if checks are required.** A commit pushed with the default `GITHUB_TOKEN` doesn't trigger new workflow runs, so required status checks on the new head commit stay pending. Workflows triggered by Dependabot only see **Dependabot secrets** — add `BUMPY_GH_TOKEN` under _Settings → Secrets and variables → Dependabot_ as well as _Actions_. See [Token setup](#token-setup). - **Dependabot stops auto-rebasing PRs that have commits from someone else.** Comment `@dependabot recreate` to get a fresh branch; the workflow then regenerates the bump files. - **Fork PRs are skipped** — their branch can't be pushed to. +- **PRs into a channel branch** are diffed against that branch (`GITHUB_BASE_REF`), so only this PR's dependency changes get bump files. - `ci deps` sets a `changed` step output (`true` / `false`). - Locally, `bumpy generate --deps` writes the same files (named after the branch) without committing. diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index 0042bab..e2fe9ee 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -156,7 +156,7 @@ export async function checkCommand(rootDir: string, opts: CheckOptions = {}): Pr } } - const changedPackages = await findChangedPackages(changedFiles, packages, rootDir, config); + const changedPackages = await findChangedPackages(changedFiles, packages, rootDir, config, baseBranch); if (changedPackages.length === 0) { log.info('No managed packages have changed.'); @@ -281,12 +281,17 @@ export function resolveDirectBumpCoverage( return { missing: stillMissing, hints }; } -/** Map changed files to the packages they belong to */ +/** + * Map changed files to the packages they belong to. `compareBranch` must be the branch + * `changedFiles` was computed against (e.g. a channel branch for a PR into it), so the + * package.json field diff and catalog diff use the same base. + */ export async function findChangedPackages( changedFiles: string[], packages: Map, rootDir: string, config: BumpyConfig, + compareBranch: string = config.baseBranch, ): Promise { const changed = new Set(); @@ -331,7 +336,7 @@ export async function findChangedPackages( // package.json was the only thing that matched — only flag if a publish-affecting // field actually changed (a dev-only dependency bump shouldn't require a release). if (!changed.has(name) && pkgJsonOnlyTrigger) { - baseRef ??= getBaseCompareRef(rootDir, config.baseBranch); + baseRef ??= getBaseCompareRef(rootDir, compareBranch); if ( await packageJsonAffectsRelease(rootDir, baseRef, pkgRelDir, ignoredFields, pkg.bumpy?.releaseTriggeringDevDeps) ) { @@ -342,7 +347,7 @@ export async function findChangedPackages( // Catalog change detection: if a catalog file changed, find packages whose // catalog: dep references resolve to a changed catalog entry - const catalogChanges = await getChangedCatalogEntries(rootDir, config.baseBranch, changedFiles); + const catalogChanges = await getChangedCatalogEntries(rootDir, compareBranch, changedFiles); if (catalogChanges.size > 0) { for (const [name, pkg] of packages) { if (changed.has(name)) continue; diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index f8191ae..0d5dbad 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -191,7 +191,7 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi } // Check if any managed packages actually changed — if not, no bump file is needed - const changedPackages = await findChangedPackages(changedFiles, packages, rootDir, config); + const changedPackages = await findChangedPackages(changedFiles, packages, rootDir, config, compareBranch); if (changedPackages.length === 0 && parseErrors.length === 0) { log.info('No managed packages have changed — no bump files needed.'); return; @@ -281,7 +281,7 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi coveredPackages.add(release.name); } } - const changedPackages = await findChangedPackages(changedFiles, packages, rootDir, config); + const changedPackages = await findChangedPackages(changedFiles, packages, rootDir, config, compareBranch); const missing = changedPackages.filter((name) => !coveredPackages.has(name)); if (missing.length > 0) { const willFail = opts.strict && !opts.noFail; @@ -331,7 +331,10 @@ export async function ciDepsCommand(rootDir: string, opts: DepsOptions): Promise } const { packages } = await discoverWorkspace(rootDir, config); - const baseRef = getBaseCompareRef(rootDir, config.baseBranch); + // Diff against the branch this PR targets — for a PR into a channel branch, using + // baseBranch would attribute the whole cycle's dependency drift to this PR. + const compareBranch = process.env.GITHUB_BASE_REF || config.baseBranch; + const baseRef = getBaseCompareRef(rootDir, compareBranch); const changes = await detectDependencyChanges(rootDir, config, packages, baseRef); const suffix = `pr${prNumber}`; const plan = planDependencyBumpFiles(changes, packages, config, suffix); diff --git a/packages/bumpy/test/core/dep-bump-files.test.ts b/packages/bumpy/test/core/dep-bump-files.test.ts index 692fce2..2968df4 100644 --- a/packages/bumpy/test/core/dep-bump-files.test.ts +++ b/packages/bumpy/test/core/dep-bump-files.test.ts @@ -12,6 +12,9 @@ import { import { discoverWorkspace } from '../../src/core/workspace.ts'; import { getBaseCompareRef } from '../../src/core/git.ts'; import { loadConfig } from '../../src/core/config.ts'; +import { findChangedPackages } from '../../src/commands/check.ts'; +import { ciDepsCommand } from '../../src/commands/ci.ts'; +import { getChangedFiles } from '../../src/core/git.ts'; import { createTempGitRepo, cleanupTempDir, gitInDir } from '../helpers.ts'; describe('formatDependencyChange', () => { @@ -254,4 +257,78 @@ describe('dependency bump files (git integration)', () => { expect(plan.files).toEqual([]); expect(plan.uncoverable).toEqual(['cli-bin']); }); + + describe('PR into a channel branch', () => { + const ENV_KEYS = [ + 'GITHUB_HEAD_REF', + 'GITHUB_BASE_REF', + 'BUMPY_PR_NUMBER', + 'GITHUB_EVENT_NAME', + 'GITHUB_EVENT_PATH', + 'GITHUB_OUTPUT', + 'GITHUB_REPOSITORY', + ]; + let savedEnv: Record; + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; + }); + afterEach(() => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + /** main → `dev` (with a merged runtime dep update) → PR branch with a dev-only update */ + async function setupChannelDrift(): Promise { + await setup({ + app: { + name: 'app', + version: '1.0.0', + dependencies: { react: '^18.0.0' }, + devDependencies: { vitest: '^1.0.0' }, + }, + }); + gitInDir(['checkout', '-b', 'dev', 'main'], tmpDir); + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + dependencies: { react: '^18.3.0' }, + devDependencies: { vitest: '^1.0.0' }, + }); + gitInDir(['commit', '-am', 'react (already merged into dev)'], tmpDir); + gitInDir(['push', '-u', 'origin', 'dev'], tmpDir); + gitInDir(['checkout', '-b', 'dependabot/vitest', 'dev'], tmpDir); + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + dependencies: { react: '^18.3.0' }, + devDependencies: { vitest: '^2.0.0' }, + }); + gitInDir(['commit', '-am', 'vitest (dev only)'], tmpDir); + } + + test('ci deps diffs against the PR base, not baseBranch', async () => { + await setupChannelDrift(); + Object.assign(process.env, { + GITHUB_HEAD_REF: 'dependabot/vitest', + GITHUB_BASE_REF: 'dev', + BUMPY_PR_NUMBER: '9', + }); + await ciDepsCommand(tmpDir, { noPush: true }); + const files = await readdir(resolve(tmpDir, '.bumpy')).catch(() => [] as string[]); + expect(files.filter((f) => f.startsWith('deps-'))).toEqual([]); + }); + + test('findChangedPackages uses the compare branch for the package.json field diff', async () => { + await setupChannelDrift(); + const config = await loadConfig(tmpDir); + const { packages } = await discoverWorkspace(tmpDir, config); + const changedFiles = getChangedFiles(tmpDir, 'dev'); + expect(await findChangedPackages(changedFiles, packages, tmpDir, config, 'dev')).toEqual([]); + // vs main, the merged react update shows up as drift + expect(await findChangedPackages(changedFiles, packages, tmpDir, config)).toEqual(['app']); + }); + }); });