Diff dependency lockfiles between git refs or PRs and emit a structured report. Works as a GitHub Action, CLI, or Node.js library.
Supports Python, JavaScript, Deno, and PHP ecosystems — including automatic detection of tool migrations (e.g. poetry → uv). Monorepo-aware: all lockfiles in the repository are auto-discovered.
name: Dependency review
on:
pull_request:
push:
branches: [main]
jobs:
lockdelta:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Diff dependencies
id: lockdelta
uses: lachaib/lockdelta@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Use the diff
run: echo '${{ steps.lockdelta.outputs.diff }}'No inputs are required on pull_request or push events. The action reads the relevant SHAs from the GitHub event payload automatically and fetches everything through the GitHub API — no actions/checkout needed.
| Event | What is compared |
|---|---|
pull_request |
PR base commit → head commit |
push |
SHA before the push → SHA after the push |
GITHUB_TOKEN is provided automatically by GitHub Actions — no extra secrets needed.
First push to a branch: when
beforeis the null SHA (no previous commit on the branch), the action skips rather than erroring.
| Input | Description | Default |
|---|---|---|
pr-number |
GitHub PR number. Auto-detected from the event payload on pull_request events — usually not needed. |
Auto |
base-ref |
Base git ref (branch, tag, SHA). Reads GITHUB_BASE_REF in CI. |
HEAD~1 |
head-ref |
Head git ref. Reads GITHUB_HEAD_REF in CI. |
HEAD |
repo |
GitHub repo in OWNER/NAME format. Auto-detected from GITHUB_REPOSITORY. |
Auto |
lockfile |
Specific lockfile path. Auto-discovers all if omitted. | — |
type |
Force lockfile type: uv, poetry, pdm, npm, yarn, pnpm, bun, deno, composer. Used with lockfile. |
— |
filters |
YAML map of named package groups → boolean outputs (see Filters). | — |
markdown |
Set to 'true' to generate a markdown summary output. |
false |
json-to-file |
File path to write the JSON report to. | — |
markdown-to-file |
File path to write the markdown summary to. Requires markdown: 'true'. |
— |
post-comment |
'true' always posts/updates a comment. 'if-changed' posts only when at least one dependency changed. 'false' never posts. Requires pull-requests: write. |
false |
| Output | Description |
|---|---|
diff |
Full JSON diff report (see Output schema) |
markdown |
Markdown summary (Added / Changed / Removed). Set when markdown: 'true'. |
<group> |
One boolean output per group defined in filters. |
When markdown: 'true' or post-comment is not 'false', lockdelta generates a three-section markdown summary. Direct production dependencies are bold, dev dependencies are italic, and transitive deps are plain. Package names link to their public registry (PyPI, npmjs, jsr.io). Packages sourced from a private registry are shown without a link; GitHub Packages scoped packages link to their GitHub repository page instead.
- name: Diff dependencies
id: lockdelta
uses: lachaib/lockdelta@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
markdown: 'true'
post-comment: 'if-changed'(pull-requests: write permission required on the job for post-comment.)
Example output:
### Changed
- **[requests](https://pypi.org/project/requests/)**: `2.31.0` → `2.32.3`
- *[pytest](https://pypi.org/project/pytest/)*: `8.0.0` → `8.1.0`
- [certifi](https://pypi.org/project/certifi/): `2024.2.2` → `2024.7.4`
### Added
- **[httpx](https://pypi.org/project/httpx/)**: `0.27.0`Inspired by dorny/paths-filter, the filters input lets you name groups of packages. Each group produces a boolean output you can use to gate subsequent steps.
- name: Diff dependencies
id: lockdelta
uses: lachaib/lockdelta@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
filters: |
auth:
- pyjwt
- cryptography
- authlib
http-client:
- httpx
- requests
- urllib3
- name: Run auth tests
if: steps.lockdelta.outputs.auth == 'true'
run: pytest tests/auth/
- name: Run integration tests
if: steps.lockdelta.outputs.http-client == 'true'
run: pytest tests/integration/name: Dependency review
on:
pull_request:
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Diff dependencies
id: lockdelta
uses: lachaib/lockdelta@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
markdown: 'true'
post-comment: 'if-changed'
filters: |
security:
- pyjwt
- cryptography
- certifi
- name: Block on security package changes
if: steps.lockdelta.outputs.security == 'true'
run: |
echo "Security-sensitive packages changed — manual review required."
exit 1npx lockdelta [options]Note:
GITHUB_TOKENmust be set in the environment when using--pr.
--base <ref> Base git ref (default: HEAD~1, or GITHUB_BASE_REF)
--head <ref> Head git ref (default: HEAD, or GITHUB_HEAD_REF)
--pr <number> GitHub PR number (uses GitHub API for exact SHAs)
--repo <owner/name> GitHub repository (auto-detected from GITHUB_REPOSITORY or git remote)
--lockfile <path> Compare a specific lockfile only
--type <type> Force lockfile type: uv, poetry, pdm, npm, yarn, pnpm, bun, deno, composer
--old <path> Old lockfile path (local file comparison mode)
--new <path> New lockfile path (local file comparison mode)
--output <path> Write JSON to file instead of stdout
# Compare against previous commit (git repo)
lockdelta
# Compare two specific refs
lockdelta --base main --head my-feature-branch
# Compare a GitHub PR by number
GITHUB_TOKEN=ghp_... lockdelta --pr 123 --repo owner/myrepo
# Compare two local lockfiles directly
lockdelta --old old/uv.lock --new new/uv.lock
# Filter to direct dependencies only
lockdelta --pr 123 | jq '.lockfiles[].changes[] | select(.is_direct)'import { run } from 'lockdelta';
// Compare a PR (requires GITHUB_TOKEN in environment)
const report = await run({ prNumber: '123', repo: 'owner/myrepo' });
// Compare git refs
const report = await run({ base: 'main', head: 'my-branch' });
// Compare local files
const report = await run({ oldFile: './old.lock', newFile: './new.lock' });
console.log(report.summary);
// { added: 1, removed: 0, updated: 3, total_changes: 4, ecosystems: ['python'] }import { registerEcosystem } from 'lockdelta';
import type { Ecosystem, DirectDeps, PackageEntry } from 'lockdelta';
const rubyEcosystem: Ecosystem = {
name: 'ruby',
supportedLockfiles: [{ filename: 'Gemfile.lock', type: 'bundler' }],
manifestName: 'Gemfile',
getLockfileType: (filename) => filename === 'Gemfile.lock' ? 'bundler' : undefined,
parseLockfile: (content, _type): Record<string, PackageEntry> => {
// parse and return { packageName: { version, registryUrl? } }
return {};
},
parseDirectDeps: (content): DirectDeps => ({ prod: new Set(), dev: new Set() }),
normalizeName: (name) => name.toLowerCase(),
};
registerEcosystem(rubyEcosystem);interface PackageChange {
name: string;
change_type: 'added' | 'removed' | 'updated';
old_version: string | null;
new_version: string | null;
is_direct: boolean; // declared in the project manifest
is_dev: boolean; // declared in a dev/optional dependency section
old_registry_url?: string; // registry origin of the old version (e.g. 'https://npm.pkg.github.com')
new_registry_url?: string; // registry origin of the new version
// Both fields present on 'updated' changes: a mismatch signals a potential registry switch
}
interface DiffReport {
schema_version: '1';
generated_at: string; // ISO 8601
base_ref: string;
head_ref: string;
summary: {
added: number;
removed: number;
updated: number;
total_changes: number;
ecosystems: string[]; // e.g. ['python', 'javascript']
};
lockfiles: Array<{
path: string | null;
workspace: string; // '.' for root, 'packages/backend' for monorepos
type: string | null; // e.g. 'uv' | 'poetry' | 'npm' | 'yarn'
ecosystem: string; // e.g. 'python' | 'javascript' | 'deno' | 'php'
summary: { added: number; removed: number; updated: number; total_changes: number };
changes: PackageChange[];
migration: { // non-null when the lockfile tool changed between refs
note: string;
base_lockfile: string | null;
base_lockfile_type: string | null;
head_lockfile: string | null;
head_lockfile_type: string | null;
} | null;
}>;
}| Lockfile | Tool | Notes |
|---|---|---|
uv.lock |
uv | |
poetry.lock |
Poetry | |
pdm.lock |
PDM | |
pylock.toml |
pip / any (PEP 751) | standard format |
Manifest: pyproject.toml. Direct deps are read from [project].dependencies (prod) and [project.optional-dependencies], [tool.poetry.group.*], [tool.uv.dev-dependencies], [dependency-groups] (dev).
| Lockfile | Tool | Notes |
|---|---|---|
package-lock.json |
npm | v1, v2, v3 |
yarn.lock |
Yarn | Classic (v1) and Berry (v2+) |
pnpm-lock.yaml |
pnpm | v5, v6, v9 |
bun.lock |
Bun | v1.2+ |
Manifest: package.json. dependencies, optionalDependencies, peerDependencies → prod. devDependencies → dev.
| Lockfile | Tool |
|---|---|
deno.lock |
Deno |
Manifest: deno.json. Both npm and JSR packages are tracked (JSR packages are prefixed with jsr: to avoid name collisions).
| Lockfile | Tool |
|---|---|
composer.lock |
Composer |
Manifest: composer.json. require → prod (platform requirements like php, ext-*, and lib-* are excluded). require-dev → dev.
The registryUrl field is extracted from each package's dist.url in the lockfile. For public Packagist packages this points to the GitHub CDN (api.github.com) since Packagist proxies GitHub releases. For private registries (Private Packagist, Satis) it reflects the self-hosted registry origin, making registry changes visible in the diff.
Migrations between lockfile formats within the same ecosystem are detected automatically (e.g. poetry → uv).
See CONTRIBUTING.md for development setup, how to add a new ecosystem, and contribution guidelines.
Apache 2.0 — Copyright 2026 Louis-Amaury Chaib. See LICENSE.