Skip to main content

unripe

PyPI CI License: MIT

unripe checks your lockfiles and fails if any dependency version was published too recently. The default is 3 days. It works across npm, pnpm, yarn, bun, Deno, PyPI, Cargo, Go, Composer, RubyGems, NuGet, Gradle, Dart and Flutter, CocoaPods, Swift, Terraform and Conan, and it can check the actions your GitHub workflows use too. A repo with a Python backend, a JavaScript frontend and a Flutter app gets one check and one policy instead of a different setting in every tool.

It runs in CI, as a pre-commit hook, from Python, and as an MCP server so AI coding agents check a version's age before they add it.

Here it is on a small test project with Deno, Python and PHP parts, the evening of October 21, 2026. uv add had just locked a certifi released two days earlier:

$ unripe check
unripe: checked 867 versions in 4 lockfiles, minimum age 3d

unripe: 2 versions published less than 3d ago
  composer laravel/framework 12.70.1  13h old, published 2026-10-21 12:55 UTC, old enough at 2026-10-24 12:55 UTC
           php/composer.lock:1
           12.70.0 is old enough (published 2026-10-13)
  pypi     certifi 2026.10.19         47h old, published 2026-10-20 02:24 UTC, old enough at 2026-10-23 02:24 UTC
           api/pylock.toml:8, api/uv.lock:6
           2026.10.18 is old enough (published 2026-10-18)

fail: 2 unripe, 0 unknown, 865 ok, 0 excluded

Nothing is wrong with either of those. That's normal. Most new versions are fine. The point is that when one isn't, you find out after everyone else does, not before. And when one is too new, unripe tells you which older version would pass.

Why bother

When a popular package gets hijacked, the bad version is usually spotted and pulled within hours. In August 2026 a worm went through npm and published malicious versions of 444 packages in about four hours, stealing CI and cloud credentials from whoever installed them. In March, two backdoored LiteLLM releases sat on PyPI for somewhere between 40 minutes and three hours. Waiting a few days before taking a new version would have skipped both.

You can check that claim. Here is a lockfile with the debug and chalk versions from the September 2025 npm compromise, checked as of two hours after they went out:

$ unripe check --as-of 2025-09-08T15:00Z
unripe: checked 3 versions in 1 lockfile, minimum age 3d

unripe: 2 versions published less than 3d ago
  npm      chalk 5.6.1  1h old, published 2025-09-08 13:13 UTC, old enough at 2025-09-11 13:13 UTC
           package-lock.json:3
           5.6.0 is old enough (published 2025-08-17)
  npm      debug 4.4.2  1h old, published 2025-09-08 13:12 UTC, old enough at 2025-09-11 13:12 UTC
           package-lock.json:2
           4.4.1 is old enough (published 2025-05-13)

fail: 2 unripe, 0 unknown, 1 ok, 0 excluded

Run it today and both come back as unpublished, which is how npm takes malicious versions down, so a lockfile that still has one fails either way.

Most package managers have added a setting for this in the last year, and if yours has one you should turn it on. unripe is for what those settings don't do. It checks what is already in the lockfile, including whatever a bot, an agent or an old tool put there. It reports on just the versions a pull request adds. It applies one policy to every ecosystem in the repo. And it covers Go, Composer, NuGet, Gradle, Dart, CocoaPods, Swift, Terraform, Conan and GitHub Actions, which don't have a setting yet.

docs/case-studies.md replays more incidents the same way, and docs/threat-model.md says plainly what a cooldown does not protect against.

Install

You need Python 3.11 or newer. unripe has no dependencies, on purpose: a tool that checks your supply chain shouldn't add to it.

uvx unripe check

or pipx install unripe, or pip install unripe. To start a config and a GitHub workflow in one go:

unripe init --github

unripe doctor checks the setup: which config and lockfiles it finds, whether each lockfile reads, and whether each registry they use answers.

Examples

Check everything under the current directory:

unripe

Only the versions a branch adds, the way a pull request check does:

unripe check --since origin/main

Pick a version before adding it. Without a version, explain lists recent releases and marks the newest one that is old enough:

$ unripe explain pypi certifi
pypi certifi: 77 versions, minimum age 3d
  2026.10.19  2026-10-20 02:24 UTC    2d 8h ago  too new, old enough at 2026-10-23 02:24 UTC
  2026.10.18  2026-10-18 16:21 UTC   3d 19h ago  old enough  <- newest old enough
  2026.7.22   2026-07-22 03:35 UTC      13w ago  old enough
  ...

A stricter rule for one ecosystem and a looser one for type packages, in .unripe.toml:

min-age = "3d"

[min-age-for]
npm = "7d"
"npm:@types/*" = "1d"

Your own packages are always new; skip them:

unripe check --exclude "npm:@myorg/*"

A version you need now, let through until a date, with the reason in the report:

[[allow]]
package = "npm:axios@1.13.2"
reason = "fixes the redirect bug we hit in production"
until = 2026-11-15

Results for a script:

unripe check --format json | jq -r '.findings[] | select(.state == "unripe") | "\(.name) \(.version)"'

Quiet unless something is wrong, for a git hook or a cron job:

unripe check --quiet

Lockfiles it reads

File Publish times come from
package-lock.json, npm-shrinkwrap.json npm registry
pnpm-lock.yaml npm registry
yarn.lock (classic and berry) npm registry
bun.lock npm registry
deno.lock npm registry and jsr.io
uv.lock the lockfile's own upload times
pylock.toml, pylock.*.toml the lockfile's own upload times, else PyPI
poetry.lock, Pipfile.lock PyPI
requirements*.txt, constraints*.txt PyPI (exact == pins only)
Cargo.lock crates.io sparse index
go.mod Go module proxy and checksum database
composer.lock the lockfile's own time field, else Packagist
Gemfile.lock rubygems.org
packages.lock.json nuget.org
gradle.lockfile, gradle/dependency-locks/*.lockfile Maven Central, Google's Maven repository, the Gradle plugin portal
pubspec.lock (Dart and Flutter) pub.dev
Podfile.lock the CocoaPods trunk
Package.resolved (Swift) GitHub releases, for packages on GitHub
.terraform.lock.hcl the Terraform registry
conan.lock (Conan 1 and 2) ConanCenter, by recipe revision
.github/workflows/*.yml, action.yml (with --workflows) GitHub releases

Dependencies from git, a local path, a workspace or another registry are skipped, since there's nothing public to ask about them. The old binary bun.lockb can't be read; bun 1.2 and later write a text bun.lock.

NuGet's packages.lock.json doesn't record which feed a package came from, so unripe looks everything up on nuget.org. Packages from a private feed come back unknown; exclude them. For Gradle, artifacts that live in another Maven repository (Confluent's, say) can be found by adding it to maven-repositories in the config.

For Go it reads go.mod, not go.sum. Since Go 1.17, go.mod lists every module in the build, while go.sum also remembers versions that aren't used.

Swift packages are dated like GitHub Actions, below, so only packages hosted on GitHub are checked. Terraform providers from other registries, including OpenTofu's, are skipped. A Conan version is dated by the recipe revision the lockfile pins, since a new revision of an old version is new code; recipes with a user and channel aren't on ConanCenter and are skipped.

GitHub Actions

With --workflows (or workflows = true in the config), unripe also reads the uses: lines in your workflows and composite actions. Each one is dated by the GitHub release whose tag points at the same commit, so actions/checkout@v7 and actions/checkout@3d3c42e... are both judged by when that commit was released. A commit with no release falls back to its commit time. An action used at a branch, like @main, has no release to go by, so it comes back unknown. Set GITHUB_TOKEN to get past GitHub's limit of 60 requests an hour; in the Action it is set for you.

A commit time is whatever the commit's author says it is, so a version that passes on one is marked: by its commit time in the text report and "dated_by": "commit" in JSON.

In GitHub Actions

name: unripe
on: pull_request

permissions:
  contents: read
  pull-requests: write  # only needed for comment: true

jobs:
  unripe:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: TexWoW/unripe@v0.6.1
        with:
          min-age: 3d
          comment: true

On a pull request it only looks at versions the PR adds, compared with the base commit. It fetches that commit if the checkout is shallow, so you don't need fetch-depth: 0. On a push it checks everything.

Each version that is too new is marked on its line in the lockfile, in the pull request's files view. The report goes to the job summary, and with comment: true also to a comment on the pull request, so reviewers see it without opening the logs. That's one comment, posted when something is too new or unknown and edited in place on later runs, so it doesn't pile up. If the job can't comment, for example on a pull request from a fork, it says so in the log and the check result stays the same.

Pin the action to a full commit sha rather than a tag if you can. The action runs unripe from its own checkout, so the code that runs is the commit you pinned, and nothing gets downloaded from PyPI.

Inputs:

Input Default
min-age 3d 90m, 72h, 3d, 1w
since PR base a git ref, or none to check everything
paths whole repo files or directories, space or newline separated
exclude patterns, one per line
unknown fail set to warn to let unknown ages pass
fail true set to false to report without failing, while you try it out
workflows false also check the actions your workflows use
annotations true mark problems on the lockfile lines
summary true write the job summary
sarif also write SARIF to this path
comment false comment on the pull request when something is too new or unknown
github-token github.token the token used for that comment and for dating actions
config a config file other than the defaults

Outputs: result (pass, fail or error), unripe and unknown (how many versions), and report and json (paths to the markdown and JSON reports).

If you want the results as code scanning alerts, write SARIF and upload it (this needs security-events: write):

      - uses: TexWoW/unripe@v0.6.1
        with:
          sarif: unripe.sarif
      - uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: unripe.sarif

As a pre-commit hook

repos:
  - repo: https://github.com/TexWoW/unripe
    rev: v0.6.1
    hooks:
      - id: unripe

It runs when a lockfile changes. Use the unripe-changed hook instead if you only want it to look at what the commit adds.

In GitLab CI

include:
  - remote: https://raw.githubusercontent.com/TexWoW/unripe/v0.6.1/ci/gitlab.yml

That adds an unripe job to the test stage. On merge requests it checks only the versions the merge request adds; on the default branch it checks everything. Findings show in the merge request's code quality widget and test report, and the markdown report is kept as a job artifact. Set UNRIPE_MIN_AGE or UNRIPE_ARGS (for example --exclude npm:@myorg/*) in your own variables: to change it.

In other CI systems

docs/ci.md has pipelines for Azure Pipelines, Jenkins, CircleCI and Bitbucket. --format junit works with all of them, and --report FORMAT:FILE writes any format to a file next to the normal output. docs/bots.md covers Renovate and Dependabot: set their cooldowns too, so they stop opening pull requests unripe would fail.

With AI coding agents

claude mcp add unripe -- uvx unripe@0.6.1 mcp

unripe mcp is a Model Context Protocol server with three read-only tools: check_package (how old a version is, or the newest version that is old enough), check_lockfiles and cooldown_settings. It works with Claude Code, Codex, Copilot in VS Code, Cursor, Claude Desktop, Windsurf and Gemini CLI; docs/agents.md has the setup for each, and a line to put in AGENTS.md so the agent uses it before adding a dependency.

Configuration

Put settings in .unripe.toml, or under [tool.unripe] in pyproject.toml. unripe looks in the current directory and then each one above it, up to the top of the git repository, or reads the file named in UNRIPE_CONFIG. Flags on the command line win. Excludes from both are combined.

min-age = "3d"
unknown = "fail"
paths = ["web", "api"]
maven-repositories = ["https://packages.confluent.io/maven"]
exclude = [
  "npm:@myorg/*",
  "left-pad@1.3.0",
]

# check workflow actions too
workflows = true

# let a version through early when a security advisory names it as the fix
allow-security-fixes = true

# a different minimum age for an ecosystem or some packages
[min-age-for]
npm = "7d"
"npm:@types/*" = "1d"

# one version let through early, with a reason and an end date
[[allow]]
package = "npm:axios@1.13.2"
reason = "fixes the redirect bug we hit in production"
until = 2026-11-15

An exclude is a package name or a glob. Prefix it with an ecosystem (npm:, pypi:, cargo:, go:, composer:, rubygems:, nuget:, maven:, jsr:, actions:, pub:, cocoapods:, swift:, terraform:, conan:) to limit it to one, and add @version to limit it to certain versions, like @types/node@20.*. Gradle packages are named group:artifact, like maven:com.mycorp:*. PyPI names are normalised the way PyPI does it, so Typing_Extensions and typing-extensions are the same package, and NuGet names ignore case.

Excludes are for packages you never want checked, like your own. For a version you need now, use [[allow]] instead. It only lets that version through while it is too new, it shows up in the report as allowed, and once the until date has passed it stops applying. unripe also says when an allow entry is no longer needed because the version has aged past the minimum.

min-age-for takes an ecosystem name or a package pattern. A package pattern wins over an ecosystem, and the first pattern that matches is used.

allow-security-fixes asks osv.dev about each version that is too new. If an advisory names that exact version as the fix, the version is allowed through and the report says which advisory. A newer version that happens to come after the fix doesn't count, and neither do malware reports. It's off by default, since it means one more request for each version that is too new.

unripe config shows the settings in use and where they came from. It also reads the cooldown settings your package managers and update bots already have (.npmrc, pnpm-workspace.yaml, .yarnrc.yml, bunfig.toml, uv, Poetry, Deno, Cargo, Bundler, Dependabot and Renovate), says where they disagree with unripe, and prints the lines you could add for the ones that are missing.

Command line

unripe check [paths...]
  --min-age AGE             default 3d
  --since REF               only versions added since this git ref
  --exclude PATTERN         can be repeated
  --unknown fail|warn       default fail
  --allow-security-fixes    let through versions an advisory names as the fix
  --workflows               also check GitHub Actions in workflows
  --format FORMAT           text, json, sarif, markdown, github, codequality or junit
  --output FILE             write the report here instead of stdout
  --summary FILE            also append a markdown report, e.g. $GITHUB_STEP_SUMMARY
  --sarif FILE              also write a SARIF report
  --json FILE               also write a JSON report
  --report FORMAT:FILE      also write any other format, can be repeated
  --annotate                also print GitHub annotations
  --as-of TIME              check as if it were then, like 2025-09-08T15:00Z
  --exit-zero               report, but don't fail
  --offline                 use only cached publish times
  --no-suggest              don't look for an older version that passes
  --config FILE
  --no-cache
  --verbose                 list the versions that passed too, and the timing
  --quiet                   print nothing when the check passes

unripe explain ECOSYSTEM NAME [VERSION]
unripe config
unripe doctor [paths...]
unripe init [--github] [--dry-run]
unripe mcp

unripe with no command runs unripe check. It exits 0 when everything is old enough, 1 when something is too new or has an unknown age, and 2 when a lockfile can't be read, no lockfile was found, or an option is wrong. The JSON report's fields are described in docs/json.md.

explain answers a single question:

$ unripe explain npm left-pad 1.3.0
npm left-pad 1.3.0: published 2018-04-09 01:10 UTC, 8y ago
old enough: the minimum age is 3d

Leave the version out to see the recent releases and which one is the newest you could use today:

$ unripe explain composer laravel/framework
composer laravel/framework: 1303 versions, minimum age 3d, 11 prereleases not shown
  12.70.1  2026-10-21 12:55 UTC      13h ago  too new, old enough at 2026-10-24 12:55 UTC
  13.38.0  2026-10-20 10:45 UTC      39h ago  too new, old enough at 2026-10-23 10:45 UTC
  13.37.0  2026-10-16 04:59 UTC   5d 21h ago  old enough  <- newest old enough

Details worth knowing

If a registry can't tell unripe when a version was published, because it's down or rate limiting or has never heard of the version, that version counts as unknown, and unknown fails. A check that quietly passes when the registry is unreachable is one an attacker can turn off. Use --unknown warn if you'd rather not. A version that npm has unpublished counts as unknown too, since that's how npm takes malicious versions down.

A publish time more than an hour in the future is not a real one, so that version counts as unknown too.

When a version is too new, unripe looks for the newest version in the same release line (same major version, or same minor below 1.0) that is old enough, skipping prereleases and anything yanked or unpublished, and preferring versions that aren't deprecated. It does this for npm, PyPI, Cargo, Composer, RubyGems, NuGet, JSR, pub.dev, CocoaPods and Terraform.

Publish times never change, so answers are cached on disk forever, in ~/.cache/unripe (%LOCALAPPDATA%\unripe\cache on Windows), or wherever UNRIPE_CACHE points. A second run on the same lockfiles makes no requests, apart from finding older versions for anything that is too new, and --offline makes none at all. Each version is looked up once even if several lockfiles have it, lookups run 16 at a time, and each keeps its connection to a registry open between requests. For npm, Cargo, Composer, RubyGems, JSR, pub.dev, CocoaPods and Terraform one request covers every version of a package. GitHub Action tags like v7 move, so those are looked up every run.

NuGet and Maven needed care too. nuget.org reports an unlisted version as published in 1900, so unripe reads its real time from the NuGet catalog instead; otherwise a version unlisted the day it came out would look 126 years old. Maven repositories never change a published file, so a POM's Last-Modified header is when the version was published, and it's there the moment the file is, before the search index catches up.

Mirrors work through UNRIPE_NPM_REGISTRY, UNRIPE_PYPI_URL and UNRIPE_CONAN_REMOTE (any server with Conan's v2 API), and UNRIPE_MAVEN_REPOS (comma separated) adds Maven repositories the same way maven-repositories does. For Go, unripe uses the first proxy in GOPROXY. Behind a corporate proxy, HTTPS_PROXY and NO_PROXY work as usual, and a company CA bundle can be given with SSL_CERT_FILE, REQUESTS_CA_BUNDLE or UNRIPE_CA_BUNDLE. A certificate error says so, rather than being retried.

Go needed extra work. The module proxy only reports a version's commit time, and a commit time is whatever the author says it is, so a tag pushed today on an old commit (or a commit dated last year on purpose) would look old. So unripe also asks Go's checksum database, sum.golang.org, which gives every module version a number in the order it first saw them and can't be rewritten. Placing that number against the timeline of the Go module index says roughly when the version first appeared. If the commit claims to be older than that, unripe goes by first appearance. For recent versions the estimate is within minutes; for old ones, where it can't narrow things down, it uses the youngest age it can't rule out. GOSUMDB, GONOSUMDB and GOPRIVATE are honoured, and private modules fall back to the commit time. docs/go.md explains how it works.

uv.lock, pylock.toml and composer.lock carry publish times themselves and unripe trusts them. Someone who can edit your lockfile can change those times, but they could also point it at any package they like, so that isn't something unripe tries to defend against.

Next to the built-in settings

As of October 2026:

Setting
npm 11.10+ min-release-age in .npmrc, in days
pnpm 10.16+ minimumReleaseAge in pnpm-workspace.yaml, in minutes (on by default since pnpm 11, at one day)
yarn 4.10+ npmMinimalAgeGate in .yarnrc.yml
bun 1.3+ minimumReleaseAge in bunfig.toml, in seconds
Deno minimumDependencyAge in deno.json (on by default since Deno 2.9, at 24 hours)
uv exclude-newer, a date or a duration like 7 days
pip 26.1+ --uploaded-prior-to P3D
Poetry 2.4+ solver.min-release-age, in days
Bundler 4.0.13+ bundle config set cooldown 3, in days
Hex 2.5.0+ mix hex.config cooldown 3d
Cargo registry.global-min-publish-age, unstable now, expected stable in Rust 1.100
Composer proposed
Go, NuGet, Maven, Gradle, GitHub Actions nothing yet
Dart, CocoaPods, Swift, Terraform, Conan nothing we know of

These stop a new version at install time, which unripe can't do, so use them where you can; unripe config prints the lines for your repo. unripe is the check in CI afterwards: one policy for the whole repo, run against what actually got locked. One more difference: some of the built-in settings let a version through when they can't find its publish date. unripe fails instead.

cooldowns.dev keeps a longer, up to date list of these settings, with examples.

Using it from Python

import unripe

for finding in unripe.scan(["."], min_age="3d", exclude=["npm:@myorg/*"]):
    if finding.state != "ok":
        print(finding.package, finding.state, finding.reason)

Each finding's state is ok, unripe, unknown, allowed or excluded. scan() raises unripe.check.Problem if a lockfile can't be read or the since ref doesn't exist.

Questions

Our own packages are always new. Exclude them, e.g. npm:@myorg/*.

We need a version that came out yesterday. Add an [[allow]] entry for that exact version with a reason and an until date. If it's a security fix, allow-security-fixes = true handles it without an entry.

We use a private registry. unripe can't see into it, so those packages come back unknown. Exclude them, or use --unknown warn. For private Go modules, point GOPROXY at your own proxy.

Does this replace npm audit or similar? No. Audit tools know about reported vulnerabilities. unripe doesn't know anything about any package. It just won't trust a version until it has been around for a while. They catch different things, so run both.

Can we try it without breaking builds? Use fail: false in the Action, or --exit-zero on the command line. Everything is still reported.

Is it slow? Not very. Discourse's Gemfile.lock, Cargo's Cargo.lock and Vite's pnpm-lock.yaml, 2,048 versions between them, took 7 seconds with nothing cached and a third of a second after that.

More

Contributing

Bug reports and pull requests are welcome. CONTRIBUTING.md explains how to run the tests and how to add a new ecosystem, which is the most useful thing anyone could add right now.

License

MIT

Metadata

Release files for unripe 0.6.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for unripe 0.6.1
File Size Uploaded Provenance
unripe-0.6.1.tar.gz 131.1 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for unripe 0.6.1
File Interpreter ABI Platform
unripe-0.6.1-py3-none-any.whl Python 3 none any Details

Total release size: 206.3 kB

Release files / unripe-0.6.1.tar.gz

Download URL unripe-0.6.1.tar.gz
Size 131.1 kB
Tags Source
SHA-256 checksum
How to use checksums
0b43c79d0e259cb712a1843b34a4a33b16b25c30436e9097ec623bfee38e939b
BLAKE2b-256 checksum
How to use checksums
9487224b5dbf63fe4eb73664f85160ae4c1171607cb754f46253484cd084fe3b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.

Transparency log

Release files / unripe-0.6.1-py3-none-any.whl

Download URL unripe-0.6.1-py3-none-any.whl
Size 75.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
30e9037c3885edd22f76592da61c479618cb1b07784060e1190a46bc87875928
BLAKE2b-256 checksum
How to use checksums
2e3087ce300105199927aeaf6ea5b668a8bffc0e80ae421e13abee752ec1ff39
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.

Transparency log

Security for unripe 0.6.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for unripe-0.6.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page