Metadata-Version: 2.4
Name: update-time
Version: 0.0.44
Author: Quality-time team
Author-email: Quality-time team <quality-time@ictu.nl>
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: English
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Programming Language :: Python :: 3.16
Classifier: Topic :: Software Development
Requires-Dist: cvss==3.6
Requires-Dist: packaging==26.3
Requires-Dist: pyyaml==6.0.3
Requires-Dist: requests==2.34.2
Requires-Dist: rich==15.0.0
Requires-Dist: tomlkit==0.15.1
Requires-Python: >=3.14
Project-URL: Changelog, https://github.com/ICTU/update-time/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/ICTU/update-time/issues
Project-URL: Repository, https://github.com/ICTU/update-time
Description-Content-Type: text/markdown

# Update-time - it's time to update your dependencies

[![PyPI](https://img.shields.io/pypi/v/update-time?logo=pypi&logoColor=white)](https://pypi.org/project/update-time/) [![Python versions](https://img.shields.io/pypi/pyversions/update-time?logo=python&logoColor=white)](https://pypi.org/project/update-time/) [![License](https://img.shields.io/pypi/l/update-time)](https://github.com/ICTU/update-time/blob/main/LICENSE)

Keeping dependencies up-to-date is an important aspect of software maintenance. Update-time is a command line tool that scans your repository for [dependencies](#-what-is-updated) and updates them to their latest versions. Where possible, it [pins](#-pinning) references — no more `latest` — and adds hashes. To protect against supply chain attacks, it applies a [cooldown](#-cooldown) period. And it warns you about [stale dependencies](#-stale-dependencies), [yanked versions](#-yanked-dependencies), [vulnerable dependencies](#-vulnerable-dependencies), and [archived dependencies](#-archived-dependencies).

Update-time rewrites the files in place and logs what it did:

![Update-time's colour-coded log output](https://raw.githubusercontent.com/ICTU/update-time/main/docs/log-output.svg)

<details>
<summary>The same output as text</summary>

```console
[09:14:03] INFO     New version available for humanize in docs/requirements.txt:12: 4.15.0
                    ╭──────────────────────────────────────────────────────────────────────────────╮
                    │ Changed in 4.15.0                                                            │
                    │                                                                              │
                    │  • ⚡ Fantastic new features                                                 │
                    │  • A few bugs squashed (#42)                                                 │
                    ╰──────────────────────────────────────────────────────────────────────────────╯
           INFO     Pinned python in Dockerfile:1 to
                    3.14.6@sha256:9f2c1e7bd4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4
           INFO     New version available for actions/checkout in .github/workflows/ci.yml:17: 4.3.0
                    No changelog available!
           WARNING  Stale dependency left-pad in package.json:24: newest release 1.3.0 was published
                    512 days ago (> 365)
           WARNING  Vulnerable dependency django in docs/requirements.txt:12: version 3.2.0 has a
                    critical vulnerability, "SQL Injection in Django" (GHSA-2gwj-7jmv-h26r,
                    https://osv.dev/GHSA-2gwj-7jmv-h26r)
```

</details>

## ☰ Table of contents

- [⚡ Usage](#-usage)
  - [Getting started](#getting-started)
  - [Workflow](#workflow)
  - [Increasing rate limits](#increasing-rate-limits)
- [🔄 Updating](#-updating)
  - [📦 What is updated](#-what-is-updated)
  - [📌 Pinning](#-pinning)
  - [⏳ Cooldown](#-cooldown)
- [⚠️ Warnings](#-warnings)
  - [🕸️ Stale dependencies](#-stale-dependencies)
  - [🚫 Yanked dependencies](#-yanked-dependencies)
  - [🛡️ Vulnerable dependencies](#-vulnerable-dependencies)
  - [🗄️ Archived dependencies](#-archived-dependencies)
- [🎛️ Controlling updates and warnings per reference](#-controlling-updates-and-warnings-per-reference)
  - [The anatomy of a marker](#the-anatomy-of-a-marker)
  - [Holding a reference back](#holding-a-reference-back)
  - [Adopting hash drift](#adopting-hash-drift)
  - [Keeping a pin floating](#keeping-a-pin-floating)
  - [Bounding an update](#bounding-an-update)
  - [Writing a marker](#writing-a-marker)
  - [Seeing what a marker did](#seeing-what-a-marker-did)
  - [Redundant markers](#redundant-markers)
  - [Incorrect markers](#incorrect-markers)
  - [Invalid markers](#invalid-markers)
- [📖 Details per dependency type](#-details-per-dependency-type)
  - [Python dependencies](#python-dependencies)
  - [npm and pnpm dependencies](#npm-and-pnpm-dependencies)
  - [Maven dependencies](#maven-dependencies)
  - [GitHub Actions and pre-commit hooks](#github-actions-and-pre-commit-hooks)
  - [Node engine version and Python version](#node-engine-version-and-python-version)
  - [Docker images](#docker-images)
  - [jsDelivr npm URLs](#jsdelivr-npm-urls)
- [📮 Point of contact](#-point-of-contact)

## ⚡ Usage

### Getting started

Run Update-time without installing it using [uvx](https://docs.astral.sh/uv/):

```console
uvx update-time
```

Or install it as a [uv tool](https://docs.astral.sh/uv/concepts/tools/) so it's always available on your `PATH`:

```console
uv tool install update-time
update-time
```

<details>
<summary>Running <code>update-time -h</code> shows the full command-line interface</summary>

```console
$ update-time -h
usage: update-time [-h] [-V] [--cooldown DAYS] [--stale-after DAYS]
                   [--vulnerability-level {low,moderate,high,critical,none}]
                   [--ignore-vulnerability IDS] [--ignore-archived]
                   [--exclude-path PATHS] [--allow-hash-drift]
                   [--allow-floating-pin] [--force]
                   [--log-level {DEBUG,INFO,WARNING,ERROR}]
                   [PATH]

Scan the PATH for pinned dependencies and update them to their latest
versions, rewriting the pinned versions in place. Looks at pyproject.toml,
requirements.txt, PEP 723 inline script metadata, package.json, pom.xml,
.python-version, Dockerfiles, CircleCI configs, .gitlab-ci.yml, Docker Compose
files, Helm charts, devcontainer configs, YAML files under .github/, .pre-
commit-config.yaml, and Sphinx config. A cooldown period holds back releases
that are too fresh to trust.

positional arguments:
  PATH                  the directory to scan recursively for dependencies to
                        update; paths in the log are reported relative to it
                        (default: the current directory)

options:
  -h, --help            show this help message and exit
  -V, --version         show program's version number and exit
  --cooldown DAYS       number of days to hold back a newly published version,
                        except for references that set a cooldown of their own
                        with an # update-time: ignore[cooldown<DAYS] marker
                        (default: 7)
  --stale-after DAYS    warn when a dependency's newest release is older than
                        this many days; 0 disables the check, except for
                        references that set a threshold of their own with an #
                        update-time: ignore[stale<DAYS] marker (default: 365)
  --vulnerability-level {low,moderate,high,critical,none}
                        warn about a known vulnerability in the version a
                        dependency is pinned to when the advisory's risk level
                        is at least this severe; a vulnerability whose risk
                        level cannot be read is always warned about. Pass none
                        to switch the check off, which queries the advisory
                        database not at all, except for references that set a
                        level of their own with an # update-time:
                        ignore[vulnerable<LEVEL] marker (default: low)
  --ignore-vulnerability IDS
                        comma-separated list of advisories to never warn
                        about, wherever in the run they turn up, for example
                        GHSA-2gwj-7jmv-h26r,CVE-2021-31542. An advisory can be
                        named by any of the identifiers it is known by. To
                        silence one for a single reference instead, mark that
                        reference with an # update-time: ignore[vulnerable=ID]
                        marker
  --ignore-archived     switch the archival check off, so no dependency is
                        warned about as archived. To silence the warning for a
                        single reference instead, mark that reference with an
                        # update-time: ignore[archived] marker
  --exclude-path PATHS  comma-separated list of directories, relative to the
                        scan root, to exclude from the scan, for example
                        vendor,packages/legacy. Every file under an excluded
                        directory is skipped, on top of the always-ignored
                        build, target, node_modules, __pycache__, and hidden
                        folders. Directories are matched by relative path, not
                        by name: --exclude-path vendor excludes vendor/ at the
                        root but not sub/vendor/. Directories that don't exist
                        are ignored and logged at log-level WARNING. Absolute
                        paths, or paths that escape the scan root (../…), are
                        rejected. Run with --log-level DEBUG to see excluded
                        directories
  --allow-hash-drift    adopt hash drift instead of only warning about it.
                        Hash drift is an image tag re-pushed with another
                        digest, or a tag or branch moved to another commit.
                        Update-time adopts the new digest or commit once it is
                        older than the cooldown. Equivalent to marking every
                        reference with # update-time: allow[hash-drift] (a
                        marker that holds the reference back, such as #
                        update-time: ignore, still wins)
  --allow-floating-pin  keep every floating tag and branch in the run as it
                        is, such as python:latest, stable-2024, or main.
                        Without this flag, Update-time pins a floating image
                        tag to the version and digest it serves, and a branch
                        or another floating tag to the commit it points at.
                        Equivalent to marking every reference with # update-
                        time: allow[floating-pin] (an # update-time:
                        ignore[floating-pin] marker still pins that reference)
  --force               run even when not inside a git repository (changes are
                        made in place and cannot be reverted)
  --log-level {DEBUG,INFO,WARNING,ERROR}
                        the minimum severity of messages to log; available new
                        versions are logged at INFO (default: INFO)

Update-time exits with status 0 when it ran successfully, 1 when an error
prevented it from finishing, and 2 when any command-line argument was invalid,
including a PATH that is not inside a git repository (unless --force is
passed). Exit status does not indicate whether anything was updated. Inspect
the diff or the INFO-level log for that.
```

</details>

Update-time logs at four levels. `--log-level` sets the lowest one shown, which by default is `INFO`.

| | Level | What is logged |
| :-: | :---- | :------------- |
| 🔍 | `DEBUG` | what Update-time is doing: files it checks, [markers](#-controlling-updates-and-warnings-per-reference) it recognises, updates held back, warnings silenced, and more |
| ℹ️ | `INFO` | what Update-time changed: a version updated, a [hash pinned](#-pinning) |
| ⚠️ | `WARNING` | what needs your attention: a [stale](#-stale-dependencies), [yanked](#-yanked-dependencies), [vulnerable](#-vulnerable-dependencies), or [archived](#-archived-dependencies) dependency, [hash drift](#hash-drift), a source it cannot reach, a file it cannot parse, a marker that is [invalid](#invalid-markers), [incorrect](#incorrect-markers), or [redundant](#redundant-markers) |
| ❌ | `ERROR` | failures that stop an update, such as a package manager that is not installed |

### Workflow

The recommended workflow is to run Update-time on a dedicated branch, push it, and let CI do the verification:

1. Create a branch for the updates.
2. Run `update-time` in the root of your repository to update the dependencies in place.
3. Review the log and the changes.
4. Commit the changes and open a pull request.
5. Let your tests and checks run in CI to confirm nothing is broken before merging.

Update-time rewrites files in place, so it expects to run inside a git repository, where you can revert its changes. By default, Update-time refuses to run when the directory to scan sits outside a git repository. It then prints an error, exits with a non-zero status, and touches no files. Pass `--force` to override.

> [!NOTE]
> Being inside a repository only guarantees revertability relative to the last commit. Update-time does not check for uncommitted edits before running.

### Increasing rate limits

To raise API rate limits while updating, set the following environment variables before running Update-time:

- `GITHUB_TOKEN` — increases the GitHub API rate limit when updating GitHub Actions and pre-commit hooks, and when checking whether a repository is archived. The token only needs to read public data about a repository, its releases, and its commits, so it does not need a specific scope. A classic token with no scopes selected works. So does a fine-grained token with default read-only access to public repositories.
- `DOCKER_HUB_USERNAME` and `DOCKER_HUB_TOKEN` — authenticate to the Docker Hub API (both must be set) to increase its rate limit when updating Docker images.

## 🔄 Updating

Update-time scans your repository for version pins. It moves each pin to the newest version its source offers, once that version is older than the cooldown. It also adds a hash pin wherever the reference can hold one.

### 📦 What is updated

Update-time updates the following types of dependencies, found in the listed files, and using the listed sources. Where a package manager manages the dependencies, Update-time works through that package manager. Update-time updates the other dependency types itself:

| Dependency type | Files | Source | Updated by |
| :-------------- | :---- | :----- | :--------- |
| [Python dependencies](#python-dependencies) | `pyproject.toml`, `requirements.txt`, and PEP 723 inline script metadata (`# /// script` blocks in `*.py` files) | [PyPI](https://pypi.org) | Update-time for `requirements.txt`. For the other two, uv resolves the versions and Update-time writes the pins |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | `package.json` (and their lock files) | [npm registry](https://registry.npmjs.org) | delegated to npm or pnpm, whichever manages the `package.json` |
| [Maven dependencies](#maven-dependencies) | `pom.xml` | the repositories the project's Maven build resolves from | delegated to Maven's [versions plugin](https://www.mojohaus.org/versions/versions-maven-plugin/) |
| [Node engine version](#node-engine-version-and-python-version) | `package.json` | the Node base image in the project's Dockerfile, or the latest [Node](https://hub.docker.com/_/node) release on Docker Hub | Update-time |
| [Python version](#node-engine-version-and-python-version) | `.python-version` | the Python base image in the project's Dockerfile, or the latest [Python](https://hub.docker.com/_/python) release on Docker Hub | Update-time |
| [Docker images](#docker-images) | Dockerfiles, CircleCI configs, `.gitlab-ci.yml`, Docker Compose files, Helm charts, and devcontainer configs | OCI registries ([Docker Hub](https://hub.docker.com), `ghcr.io`, `mcr.microsoft.com`, …) | Update-time |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | YAML files under `.github/` | [GitHub API](https://api.github.com) | Update-time |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | `.pre-commit-config.yaml` | [GitHub API](https://api.github.com) | Update-time |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | Sphinx config | [npm registry](https://registry.npmjs.org) | Update-time |

Update-time rewrites the dependency types it updates itself line by line: it picks the new version from the source and edits the reference in place. It hands the types it delegates to uv, npm, or pnpm, which resolve the versions themselves. The package managers also keep the project's lock files in step. For a pyproject.toml or inline script metadata dependency pinned with `==`, uv resolves the new version and Update-time writes it into the `pyproject.toml` or the `# /// script` block. This is needed because uv has no [command to upgrade dependencies](https://github.com/astral-sh/uv/issues/6794).

In the table above, each dependency type links to its own section under [Details per dependency type](#-details-per-dependency-type). That section covers the files and dependencies the type updates. It also covers how pinning, the cooldown, staleness, yanks, vulnerabilities, archival, and markers apply to it.

### 📌 Pinning

*Pinning* means specifying exactly what a reference should resolve to, rather than leaving that to whatever its source serves at the time. Two things can be pinned: the version a reference resolves to, and the artefact that the version resolves to.

A **version pin** names an exact version rather than a floating image tag, a range, a branch, or a tag such as `stable-2024`: `python:3.14` instead of `python:latest`, `humanize==4.15.0` instead of `humanize>=4`. Its opposite is a **floating pin**, which leaves the version to the source. A floating image tag such as `python:latest`, a range such as `humanize>=4` or npm's `^17.0.0`, a branch such as the `main` in `actions/checkout@main`, and a tag such as `stable-2024` each float. Update-time updates version pins. It pins a floating image tag to the version and digest the tag serves, and a branch or a tag such as `stable-2024` to the commit it points at (see [Floating image tags](#floating-image-tags) and [GitHub Actions and pre-commit hooks](#github-actions-and-pre-commit-hooks)). It keeps a range such as `humanize>=4` or `^17.0.0` a range, because the range states which versions the project accepts. Where the project has a lock file, the package manager records there which version the range resolves to.

A **hash pin** adds a cryptographic hash of the artefact the version resolves to — an image digest, a commit SHA, or an integrity hash. The difference with a version pin is immutability. A version pin can be re-pointed under you, because a tag can be moved or re-pushed. A hash pin can only match the one thing it was computed from. That is what protects against a supply chain attack, and Update-time adds a hash pin wherever it can.

Update-time works on both version pins and hash pins. It moves a version pin forward, and takes the most precise spelling the source has for the version it lands on. And it adds a hash pin to any reference that can hold one.

#### Floating image tags

A floating image tag does not pin a version: what the project pulls is whatever the registry serves under that tag at that moment. Update-time replaces it with the version and digest it serves at the time of the run, which is the image the project pulls today:

```console
INFO Pinned python in Dockerfile:1 to 3.14.7@sha256:…
```

A reference without a tag floats the same way: `FROM python` and `image: redis` ask for whatever their registry serves under `latest`. Update-time pins such a reference to the version and digest that the tag resolves to, writing the tag after the image's name, so `FROM python` becomes `FROM python:3.14.7@sha256:4fad23465a06cc5149a541fbec6f87e234a64dc0550f6bfdd2d290d8f03240df`.

Registries name one image under several tags, so a floating tag shares its digest with the version tags naming the same image. For example, `python:latest` names the same image as `python:3.14.7`, `python:3.14`, and `python:3`. Update-time can pin the reference to any of those versions. It picks between them using these rules:

1. Update-time keeps a label the floating tag shares with a version tag, so `node:trixie` lands on `26.7.0-trixie` rather than on `26.7.0`, and `python:slim` on `3.14.7-slim`. It drops a label naming a channel — `latest`, `lts`, `stable`, or `edge` — to stop the tag from floating. So `node:lts-alpine` keeps `alpine` and drops `lts`.
2. The most precise version wins, so `3.14.7` is preferred over `3.14` and `3`.
3. Then the most precise version of a variant the floating tag names, so `node:lts-alpine` lands on `24.19.0-alpine3.24` rather than on `24.19.0-alpine`.
4. The shortest name comes last, so `node:latest` lands on `26.7.0` rather than on `26.7.0-trixie`, and `amazoncorretto:latest` on `8` rather than on `8-al2023`. This prevents adopting a label the floating tag doesn't have.

A [bound](#bounding-an-update) does not prevent pinning, so `allow[update<3.13]` on `python:latest` still pins to `3.14.7`, the version that tag serves. From the next run on, the reference is a version pin like any other, and Update-time updates, bounds, and checks it as one.

> [!IMPORTANT]
> Once pinned, a reference no longer follows a channel. Later runs move `node:lts` pinned to `24.19.0` to whatever version is newest, LTS or not. To keep the channel, mark the reference `# update-time: allow[floating-pin]` (see [Keeping a pin floating](#keeping-a-pin-floating)). To keep the pin under 25, add a bound. From the next run on, `ignore[major-update]` or `allow[update<25]` holds it there (see [Bounding an update](#bounding-an-update)).

Update-time leaves the following kinds of floating tag as they are:

- A tag whose image has no version tag, such as an image tagged only `dev` or `prod`.
- A tag the registry does not list, such as an image a pipeline builds and never pushes, a tag deleted from the repository, or a mistyped tag.
- A tag listed further down a large repository's tag list than Update-time reads.
- A tag on a registry other than Docker Hub, where Update-time reads the candidate tags one at a time and none of those it tries serves the same image.
- A tag whose registry does not serve a manifest for it — a private image Update-time cannot authenticate to, or a registry it cannot reach — so it cannot read the digest that tag serves.

Update-time reports each of them at `DEBUG`, naming the reason the tag was not pinned:

```console
DEBUG Floating tag acme/api:dev in docker-compose.yml:7 was left as it is: no tag naming a version serves the same image
DEBUG Floating tag acme/api:nightly in docker-compose.yml:7 was left as it is: nightly is not among the tags listed for the image
DEBUG Floating tag acme/api:canary in docker-compose.yml:7 was left as it is: canary is not among the newest tags examined for the image
DEBUG Floating tag ghcr.io/acme/api:latest in Dockerfile:1 was left as it is: no tag naming a version among the newest examined serves the same image
DEBUG Floating tag ghcr.io/acme/api:edge in Dockerfile:1 was left as it is: the registry does not serve a manifest for edge, so what that tag serves is unknown
```

Where Update-time reads the digest a floating tag serves, but does not find a version tag serving the same image, it cannot pin the reference to a version. That happens for an image tagged only `dev`, and on a registry other than Docker Hub when none of the tags Update-time tries serves that image. Where the reference records another digest, Update-time warns about the [hash drift](#hash-drift) instead. When the registry does not list the floating tag among those Update-time reads, or does not serve a manifest for it, Update-time cannot read the digest at all. It then leaves the reference as it is without a drift warning, since it cannot tell whether the floating tag was re-pointed.

#### Version precision

When Update-time updates a version pin, and the source offers a more precise new version, it chooses that version. For example, Update-time applies `python:3.13.0` when it updates `python:3.12` and both `python:3.13` and `python:3.13.0` are available. Every kind of version pin gains precision this way. A `.python-version` entry of `3.12` becomes `3.13.2`, and `actions/checkout@v4` moves to the exact version that tag resolves to.

However, more precision is not guaranteed: Update-time still applies a newer version that is less precise. For example, if `python:3.12.1` is the current version pin and `python:3.13` is available, but no `python:3.13.0`, the version pin moves to `python:3.13`.

A pin only gains precision by updating, so Update-time does not rewrite a pin into a more precise spelling of the version it already names. `python:3.12` stays as it is when the newest matching tag is `3.12.0`. So does `humanize==4.15` as long as `4.15.0` is the latest release.

#### Which dependencies get a hash pin

What Update-time adds depends on what the reference can hold:

| Dependency type | What Update-time adds |
| :-------------- | :-------------------- |
| [Python dependencies](#python-dependencies) | nothing: a pin names a version, and Update-time writes no hash beside it |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | nothing: the integrity hashes live in the package manager's lock file |
| [Maven dependencies](#maven-dependencies) | nothing: a pom names a version, and Maven records an artefact's checksum beside the artefact |
| [Node engine version](#node-engine-version-and-python-version) | nothing: it names a Node version, not an artefact that could be hashed |
| [Python version](#node-engine-version-and-python-version) | nothing: it names a Python version, not an artefact that could be hashed |
| [Docker images](#docker-images) | the `@sha256:digest` of the tag, appended to the reference |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | the commit SHA the version, branch, or tag points at, with a trailing comment naming the version, branch, or tag. A short commit SHA is replaced by the full one |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | the commit SHA the version, branch, or tag points at, with a `# frozen:` comment naming the version, branch, or tag. A short commit SHA is replaced by the full one |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | the SRI `integrity` hash of the file the URL points at |

#### Hash drift

Sometimes a reference already carries a hash pin, and only what it points at changed. Update-time warns about that and leaves the pin unchanged. It never silently adopts a changed target, which would defeat the immutability a pin exists to provide. Hash drift takes five forms:

```console
WARNING Digest drift for python:3.14 in Dockerfile:1: pinned to sha256:… but the registry now serves sha256:…; the pin was left unchanged, verify the change is expected before updating the pin
WARNING Tag drift for actions/checkout@4.1.1 in .github/workflows/ci.yml:17: pinned to commit … but the tag now points at …; the pin was left unchanged, verify the tag was moved deliberately before updating the pin
WARNING Branch drift for actions/checkout@main in .github/workflows/ci.yml:17: pinned to commit … but the branch has moved on to …; the pin was left unchanged, verify the branch moved to a commit you trust before updating the pin
WARNING Ref drift for actions/checkout@stable-2024 in .github/workflows/ci.yml:17: pinned to commit … but the ref now points at …; the pin was left unchanged, verify the ref moved to a commit you trust before updating the pin
WARNING Integrity hash mismatch for clipboard@2.0.11 in docs/conf.py:4: declares sha256-… but jsDelivr serves sha256-…; the hash was left unchanged, and since npm does not republish a version it is probably the declared hash that is wrong
```

*Digest drift* means an image tag was re-pushed (rebuilt) under the same name and version, so the registry now serves a different digest.

*Tag drift* means the tag of a GitHub Action or pre-commit hook now points at another commit than the one the reference pins. *Branch drift* means the branch has moved on from the commit the reference pins. The branch's newest commit older than the [cooldown](#-cooldown) differs from the pinned commit, and lies further along the branch or on a history a force-push rewrote. Update-time follows the branch's own line of commits, through the first parent of each merge. So a commit on a merged pull request's branch never counts as the branch's newest commit, whatever its date. When GitHub refuses to say whether the repository has a tag of that name, Update-time can't tell a tag from a branch. It then treats the reference as a tag, and reports *ref drift*. A git tag is mutable, so whoever controls the repository can move it. Update-time pins the reference to a commit SHA to catch that. A branch is meant to move. Mark the reference `allow[hash-drift]` only if you trust whoever moves the branch. Update-time then moves the reference to the branch's newest commit older than the cooldown (see [Adopting hash drift](#adopting-hash-drift)). It keeps doing so as long as the comment names the branch.

An *integrity hash mismatch* means the hash a jsDelivr URL declares is not the one jsDelivr serves for the version the URL sits on. Unlike the other forms this rarely means anything upstream changed, since npm does not allow a published version to be republished. More likely, the declared hash is wrong: mistyped, copied from another file, or tampered with. It is also the most urgent, because the browser silently refuses to load the script until the hash matches, which no build step catches.

A floating image tag that carries a digest, and a branch pinned to a commit, can drift as well. Where the tag or branch moved, Update-time warns about the drift. Where the tag or branch did not move, and a version tag names that image or commit, Update-time pins the reference to that version tag (see [Floating image tags](#floating-image-tags) and [GitHub Actions and pre-commit hooks](#github-actions-and-pre-commit-hooks)).

A reference without hash pin has nothing that can drift. So Update-time checks no `requirements.txt` pin, no `.python-version` entry, and no Node engine version. It checks none of the dependencies it delegates to a package manager either.

To have Update-time adopt drift, mark the reference `allow[hash-drift]` or pass `--allow-hash-drift` (see [Adopting hash drift](#adopting-hash-drift)). The marker adopts the drift of the reference it marks, run after run. The flag adopts the drift of every reference in the run. A marker that holds the reference back wins over both, so a reference you deliberately froze is never re-pinned. Update-time logs adopted drift at `INFO`, like any other change.

For a reference that adopts drift, Update-time adopts the newest digest or commit older than the [cooldown](#-cooldown), the same way an update moves to the newest version older than it. Until a new digest or commit is older than the cooldown, it holds the drift back without a warning, the same way it holds back an update. For a reference that does not adopt drift, Update-time warns about a moved tag or a re-pushed image right away, without waiting for the cooldown, since a pin exists to catch exactly that. A branch is meant to move, so Update-time warns about it only once a new commit on it is older than the cooldown, when that commit could be adopted. A registry other than Docker Hub does not date an image, so Update-time adopts a re-pushed digest there at once.

Update-time never adopts an integrity hash mismatch, whatever you opt in to. The whole point of the hash is to refuse content that doesn't match it. So Update-time reports the mismatch and leaves correcting it to you.

### ⏳ Cooldown

To avoid adopting releases that are too fresh to trust, Update-time honours a cooldown period. It does not yet adopt a version published inside that period. The cooldown defaults to **7 days**. Change it with the `--cooldown` option, for example `update-time --cooldown 14`. A reference that takes a marker can set a cooldown of its own, which wins over whatever `--cooldown` says (see [Controlling updates and warnings per reference](#-controlling-updates-and-warnings-per-reference)). Who applies it depends on the dependency type:

| Dependency type | Applied by | Measured against |
| :-------------- | :--------- | :--------------- |
| [Python dependencies](#python-dependencies) | Update-time for `requirements.txt`, uv for the rest | the release's publication date on PyPI |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | npm and pnpm | the release's age on the npm registry |
| [Maven dependencies](#maven-dependencies) | Update-time, for the artefacts Maven Central serves | the version's publication date on Maven Central |
| [Node engine version](#node-engine-version-and-python-version) | Update-time, or already applied via the Dockerfile | the Node image tag's push date |
| [Python version](#node-engine-version-and-python-version) | Update-time, or already applied via the Dockerfile | the Python image tag's push date |
| [Docker images](#docker-images) | Update-time, on Docker Hub only | the image tag's push date |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | Update-time | the release's publication date, or the date of the commit a tag or branch points at |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | Update-time | the release's publication date, or the date of the commit a tag or branch points at |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | Update-time | the release's publication date on the npm registry |

Where Update-time applies the cooldown itself, it holds back every version published inside the cooldown. Where a package manager applies the cooldown, Update-time hands the value to uv, npm, or pnpm. Each of them takes the value per run rather than per dependency. Each of them also leaves a cooldown your project already configures in place. [Python dependencies](#python-dependencies) and [npm and pnpm dependencies](#npm-and-pnpm-dependencies) describe what that means per dependency type.

> [!NOTE]
> The cooldown holds back updates and the adoption of [hash drift](#hash-drift). Pinning does not change what the reference resolves to, so the cooldown does not hold back a [pin](#-pinning).

## ⚠️ Warnings

Update-time reports what an update cannot fix:

- a dependency whose newest release is years old
- a version its maintainer withdrew
- a version a security advisory names
- a dependency its maintainer archived

These warnings are informational, so they change no file and do not affect the exit status.

### 🕸️ Stale dependencies

Update-time warns when a dependency's newest release is older than a threshold. That may mean the project was abandoned or superseded. The threshold defaults to **365 days**, and `--stale-after DAYS` sets it. Pass `--stale-after 0` to disable the check. A reference that takes a marker can set a threshold of its own, or leave the check out altogether, and either wins over whatever `--stale-after` says (see [Controlling updates and warnings per reference](#-controlling-updates-and-warnings-per-reference)). For example, Update-time reports a pin whose newest release is well over a year old as:

```console
WARNING Stale dependency humanize in docs/requirements.txt:12: newest release 4.15.0 was published 512 days ago (> 365)
```

The date compared against the threshold is the publication date of the dependency's *newest* release. So Update-time never reports a project that just published a release as stale, not even when that release is still within the [cooldown](#-cooldown).

Update-time checks the dependencies it asks a source about for staleness, against the date that source reports:

| Dependency type | Measured against |
| :-------------- | :--------------- |
| [Python dependencies](#python-dependencies) | the newest release of the package on PyPI |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | the newest release on the npm registry, for the dependencies that resolve to one |
| [Maven dependencies](#maven-dependencies) | the newest release on Maven Central, of each `pom.xml` dependency and plugin |
| [Node engine version](#node-engine-version-and-python-version) | the base image it follows, or the newest Node release on Docker Hub when no Dockerfile declares one |
| [Python version](#node-engine-version-and-python-version) | the base image it follows, or the newest Python release on Docker Hub when no Dockerfile declares one |
| [Docker images](#docker-images) | the image's newest release, and on Docker Hub only, since other registries expose no publication date |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | the newest release of the action's repository |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | the newest release of the hook's repository |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | the newest release of the package on the npm registry |

### 🚫 Yanked dependencies

A yank means "stop using this": a release the maintainer withdrew because it was broken, botched, or insecure. An exact pin keeps installing one anyway, so Update-time warns when the version a dependency is pinned to was yanked. Update-time includes the maintainer's reason when they gave one:

```console
WARNING Yanked dependency humanize in docs/requirements.txt:12: version 4.15.0 was yanked ("accidentally broke Python 3.10 support")
```

When no reason was given, the message reports `(reason not specified)` instead.

Update-time gives the warning only when the run leaves the reference on the yanked version. That happens when the replacement is still within the [cooldown](#-cooldown), or when a marker holds the update back. It also happens when a package manager left the pin where it was, or when the yanked release is the newest one. To silence the warning itself, mark the reference `# update-time: ignore[yanked]` (see [Controlling updates and warnings per reference](#-controlling-updates-and-warnings-per-reference)).

Which dependencies are checked follows from where a yank can be observed. PyPI reports one as [PEP 592](https://peps.python.org/pep-0592/) yank metadata. On npm there is no yank, but a per-version *deprecation* is the same signal, and Update-time reports it in the same wording as a yank. Where a withdrawal can be observed, Update-time skips that version when picking a new one, and warns about a reference left on it:

| Dependency type | Yank check |
| :-------------- | :--------- |
| [Python dependencies](#python-dependencies) | `requirements.txt`, `pyproject.toml`, and inline script metadata pins, against PyPI's yank metadata |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | none: npm and pnpm handle deprecated versions themselves |
| [Maven dependencies](#maven-dependencies) | none: Maven Central does not report a withdrawal |
| [Node engine version](#node-engine-version-and-python-version) | none: its source does not record a yank |
| [Python version](#node-engine-version-and-python-version) | none: its source does not record a yank |
| [Docker images](#docker-images) | none: its source does not record a yank |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | none: its source does not record a yank |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | none: its source does not record a yank |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | against the npm registry's deprecation of the pinned version |

### 🛡️ Vulnerable dependencies

Update-time queries the [OSV](https://osv.dev) database about the pinned version. OSV aggregates GitHub's advisory database, PyPI's advisory database, and others. Update-time warns when an advisory names that version as affected. The warning names the risk level, what the advisory says, and where to read the advisory in full:

```console
WARNING Vulnerable dependency django in docs/requirements.txt:12: version 3.2.0 has a critical vulnerability, "SQL Injection in Django" (GHSA-2gwj-7jmv-h26r, https://osv.dev/GHSA-2gwj-7jmv-h26r)
```

Several of the databases that OSV aggregates carry the same vulnerability, each under an identifier of its own. So OSV answers with an advisory per database. Update-time warns about a vulnerability once, whichever of them reported it. The advisories of one vulnerability name each other's identifiers, and Update-time reads them as one. The databases rate a vulnerability independently, and only some rate it at all. So the warning names the advisory that rates it most severely, and Update-time reports and filters the vulnerability at that level.

The risk level is the one the advisory's reviewers gave it: `low`, `moderate`, `high`, or `critical`. Where they gave none, Update-time derives the level from the advisory's CVSS base score: `0.1` to `3.9` is low, `4.0` to `6.9` moderate, `7.0` to `8.9` high, and `9.0` to `10.0` critical.

Update-time reads an advisory carrying both a CVSS v3 and a v4 vector at its v4 score, the newer of the two assessments. It reports an advisory whose risk level it cannot read at all as `a vulnerability of unknown severity`. It reports an advisory that gives no summary, which many do not, without the quotation. That advisory's id and URL say which vulnerability it is.

Update-time checks the version the run leaves the reference on. So it warns about a vulnerability the run updated into, and not about one the run updated away from. It does not check transitive dependencies. Auditing the transitive dependencies in a lock file is what `uv audit`, `pip-audit`, `npm audit`, and `pnpm audit` are for.

A reference that takes a marker can set a risk level of its own, silence one advisory, or leave the check out altogether (see [Controlling updates and warnings per reference](#-controlling-updates-and-warnings-per-reference)).

To silence one advisory across the whole run, rather than on the one reference that carries a marker, pass `--ignore-vulnerability`. The option takes a comma-separated list: `--ignore-vulnerability GHSA-2gwj-7jmv-h26r,CVE-2021-31542`. It names an advisory the way a marker does, so any identifier the vulnerability is known by will do. Update-time logs what the option silenced at `DEBUG`. That line names the advisory OSV answered under, and quotes back the identifiers you passed for it. Where a reference's own marker silences the same advisory, the marker is the one reported.

Update-time warns about every risk level by default. To hear only about the more severe ones, raise the threshold with `--vulnerability-level`, for example `--vulnerability-level high`. Update-time warns about a vulnerability whose risk level it cannot read, whatever the threshold is. Pass `--vulnerability-level none` to switch the check off.

Which dependencies are checked follows from what OSV can match a pinned version against:

| Dependency type | Vulnerability check |
| :-------------- | :------------------ |
| [Python dependencies](#python-dependencies) | `requirements.txt`, `pyproject.toml`, and inline script metadata pins, against OSV's PyPI advisories |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | none: those dependencies are declared as ranges and resolved in the lock file, which Update-time does not read |
| [Maven dependencies](#maven-dependencies) | the version each `pom.xml` dependency and plugin is left on, against OSV's Maven advisories |
| [Node engine version](#node-engine-version-and-python-version) | none: it names a runtime version rather than a package release |
| [Python version](#node-engine-version-and-python-version) | none: it names a runtime version rather than a package release |
| [Docker images](#docker-images) | none: OSV has no ecosystem for container images |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | none: OSV matches no version against its advisories for actions |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | none: a hook repository is not an OSV package |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | the version in the URL, against OSV's npm advisories |

### 🗄️ Archived dependencies

An archived dependency is one its maintainer declared finished: expect no further release. Update-time warns when a dependency's source declares it archived, and `--ignore-archived` switches that check off for the whole run. For example, Update-time reports an archived project and an archived repository as:

```console
WARNING Archived dependency aioredis in docs/requirements.txt:12: the project was archived
WARNING Archived dependency actions/setup-ruby in .github/workflows/ci.yml:17: the repository was archived
```

The clause after the colon names what the source archived: PyPI archives a project, GitHub a repository. The warning quotes a reason if the source publishes one.

An archived dependency is still updated to its newest release, if one is available.

Which dependencies are checked follows from where an archival declaration can be read:

| Dependency type | Archival check |
| :-------------- | :------------- |
| [Python dependencies](#python-dependencies) | the project status PyPI publishes for the package a Python file declares |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | none: the npm registry does not publish an archival signal |
| [Maven dependencies](#maven-dependencies) | whether GitHub reports the source repository of each `pom.xml` dependency and plugin as archived |
| [Node engine version](#node-engine-version-and-python-version) | none: it follows a Docker image, and no registry publishes an archival signal |
| [Python version](#node-engine-version-and-python-version) | none: it follows a Docker image, and no registry publishes an archival signal |
| [Docker images](#docker-images) | none: no OCI registry publishes an archival signal |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | whether GitHub reports the action's repository as archived |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | whether GitHub reports the hook's repository as archived |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | none: the npm registry does not publish an archival signal |

## 🎛️ Controlling updates and warnings per reference

Markers of the form `# update-time: <directive>` let you steer what happens to an individual reference. You can hold a reference back with one. You can also bound how far it may move, or opt it into behaviour that is off by default. A marker is a comment wherever the file can hold one, and a field where it cannot (see [Where to put a marker](#where-to-put-a-marker)).

To stop Update-time from changing a specific reference, add an `# update-time: ignore` comment (all lower-case). You might do this because of a known incompatibility, a deferred migration, or to keep something reproducible. You can add a reason after the marker, for example `# update-time: ignore (pinned until the 3.13 migration)`.

Update-time reports at `WARNING` what a marker gets wrong. A marker that decides nothing is [redundant](#redundant-markers), a comparison written the wrong way round is [incorrect](#incorrect-markers), and an item Update-time cannot read is [invalid](#invalid-markers). The three sections closing this chapter cover them.

> [!WARNING]
> As long as Update-time is alpha (version `0.0.X`), marker syntax and semantics are subject to change without deprecation notice or migration support.

### The anatomy of a marker

The rest of this chapter names the parts of a marker, so here is one taken apart:

```text
humanize==4.15.0  # update-time: ignore[yanked, stale<90] allow[update<5] (until 5.0)
                  └────────────────────── marker ───────────────────────┘ └ reason ─┘
                                 └───── directives ─────┘ └─ directive ─┘
                                 └verb┘└─── bracket ────┘ └verb┘└bracket┘
```

A marker is the `# update-time:` comment up to its last directive. It holds one or more directives, each a verb and the bracket that may follow it. Directives that use the same verb can be combined: `ignore[yanked, stale<90]` is equivalent to `ignore[yanked] ignore[stale<90]`. Free text after the last directive is a reason, which Update-time ignores. In a file that can hold no comment, the directives are the value of an `update-time` field instead. Everything below reads the same (see [Where to put a marker](#where-to-put-a-marker)).

Zooming in on brackets:

```text
ignore[yanked, stale<90]
       └─┬──┘  └─┬─┘└┬┘
         │       │   └─ threshold: what the item sets for this reference, here 90 days
         │       └─ scope: what the item steers, here the staleness warning
         └─ item: a scope named alone applies outright, here silencing the yank warning
```

A bracket holds one or more items, separated by commas. The `ignore` bracket above holds the two items `yanked` and `stale<90`, and the `allow` bracket holds the single item `update<5`. An item names the scope it steers. The two items above name `yanked` and `stale`. An item may also set a value for a scope, as `stale<90` sets 90 days for the staleness warning.

| Term | What it is | Example |
| :--- | :--------- | :------ |
| Marker | the directives steering one reference: a `# update-time:` comment, up to its last directive, or a field's value | `# update-time: ignore[stale] allow[update<3.13]` |
| Directive | a verb and the bracket it may carry. A marker holds one or more directives | `ignore[stale<90]` |
| Verb | `ignore` drops what its items name and `allow` keeps it, so `ignore[stale<90]` and `allow[stale>=90]` say the same thing | `ignore`, `allow` |
| Bracket | what a directive's `[…]` holds: its items, separated by commas | `[yanked, stale<90]` |
| Item | one entry in a bracket: a scope, a scope with a threshold, a [bound](#bounding-an-update), or an [advisory](#silencing-specific-vulnerabilities) | `stale<90` |
| Scope | what an item steers: `update`, `cooldown`, `stale`, `yanked`, `vulnerable`, `archived`, `hash-drift`, or `floating-pin` | `ignore[yanked]` steers the yank warning |
| Bare `ignore` | the verb with no bracket at all, which applies to every scope it can without naming one | `# update-time: ignore` |
| Reason | free text after the last directive, which Update-time ignores | `(pinned until the 3.13 migration)` |

A marker wins over the command-line option that sets the same thing, whatever that option is set to. Set a threshold, a cooldown, a risk level, or a bound once per reference. Setting one twice, with either verb, has an undefined result.

### Holding a reference back

A bare `# update-time: ignore` holds a reference back from version updates and from the [staleness](#-stale-dependencies), [yank](#-yanked-dependencies), [vulnerability](#-vulnerable-dependencies), and [archival](#-archived-dependencies) checks. Add a bracket to narrow it to one or more scopes:

| Marker | Version update | ⚠️ Staleness warning | 🚫 Yank warning | 🛡️ Vulnerability warning | 🗄️ Archival warning |
| :----- | :------------- | :---------------- | :----------- | :-------------------- | :------------------ |
| `# update-time: ignore` | held back | silenced | silenced | silenced | silenced |
| `# update-time: ignore[update]` | held back | checked | checked | checked | checked |
| `# update-time: ignore[stale]` | applied | silenced | checked | checked | checked |
| `# update-time: ignore[yanked]` | applied | checked | silenced | checked | checked |
| `# update-time: ignore[vulnerable]` | applied | checked | checked | silenced | checked |
| `# update-time: ignore[archived]` | applied | checked | checked | checked | silenced |

So `# update-time: ignore[update]` keeps a deliberately pinned reference frozen. It still tells you when the project behind the reference went quiet, when its version was withdrawn, when an advisory names that version, or when the maintainer archived the project. `# update-time: ignore[stale]` silences a staleness warning you acknowledged, without freezing the version. `# update-time: ignore[yanked]` does the same for a yank you decided to live with.

`# update-time: ignore[vulnerable]` silences the vulnerability warning for one you assessed, while the reference keeps updating. `# update-time: ignore[archived]` silences the archival warning for a dependency you decided to keep using. To silence it for every dependency in the run, pass `--ignore-archived` (see [Archived dependencies](#-archived-dependencies)). A reason can still follow the scope, for example `# update-time: ignore[update] (pinned until the 3.13 migration)`.

A bare `# update-time: ignore` and an `ignore[update]` win over any directive beside them that would change the reference: a [bound](#bounding-an-update), an [`allow[hash-drift]`](#adopting-hash-drift), or an [`allow[floating-pin]`](#keeping-a-pin-floating). Update-time leaves the reference untouched.

#### Setting a staleness threshold

`ignore[stale]` silences the staleness warning altogether. To keep the warning but on a different schedule, give the scope a number of days. `# update-time: ignore[stale<90]` warns once that reference's newest release is more than 90 days old, and is a per-reference `--stale-after 90`. Use it for a critical dependency you want to hear about early, or for a low-churn library that shouldn't be flagged for years:

```text
humanize==4.15.0  # update-time: ignore[stale<90] (critical, warn early)
```

```dockerfile
# update-time: ignore[stale<1095]
FROM python:3.12
```

The threshold applies to the reference carrying it, and every other reference in the run keeps the global one. It wins over `--stale-after`, `--stale-after 0` included, so disabling the check globally still leaves a reference with its own threshold checked. To disable the check for one reference, use `ignore[stale]`. Where a reference carries both a threshold and a bare `ignore[stale]`, the `ignore[stale]` wins and silences the warning whatever the threshold says. `allow[stale>=90]` sets the same 90-day threshold as `ignore[stale<90]`.

#### Setting a cooldown period

The [cooldown](#-cooldown) holds back releases that are too fresh to trust. To put one reference on a different cooldown from the rest, give a `cooldown` scope a number of days. `# update-time: ignore[cooldown<30]` drops update candidates published less than 30 days ago, and is a per-reference `--cooldown 30`. Use it for a dependency that burned you before, or one you trust enough to adopt sooner than the rest:

```text
some-flaky-lib==2.1.0  # update-time: ignore[cooldown<30] (burned by 2.0.0)
```

```dockerfile
# update-time: ignore[cooldown<30]
FROM python:3.12
```

The cooldown applies to the reference carrying it, and every other reference in the run keeps the global one. It wins over `--cooldown`. `allow[cooldown>=30]` sets the same 30-day cooldown as `ignore[cooldown<30]`. To adopt new releases for one reference as soon as they ship, write `allow[cooldown>=0]` or `ignore[cooldown<0]`. A zero-day cooldown holds nothing back, which is what `--cooldown 0` means globally.

The override reaches the dependencies whose cooldown Update-time enforces itself. It does nothing for the dependencies handed to uv, npm, or pnpm, which take a cooldown per run rather than per dependency (see [Cooldown](#-cooldown)).

#### Silencing specific vulnerabilities

`ignore[vulnerable]` silences every [vulnerability](#-vulnerable-dependencies) warning a reference gets. To silence just one of them — a vulnerability you assessed and decided to live with — name the advisory after the scope:

```text
django==3.2.0  # update-time: ignore[vulnerable=GHSA-2gwj-7jmv-h26r] (assessed, we don't use the affected query API)
```

Any identifier the vulnerability is known by will do. OSV holds an advisory per database, each under an id of its own. So a marker naming the `CVE-…` silences a warning reported under the `GHSA-…`. The `DEBUG` line reporting the silenced warning names that `GHSA-…`. Your own marker is quoted beside it, so you can tell which of the two you wrote.

To silence a second advisory, add a second item: `# update-time: ignore[vulnerable=GHSA-2gwj-7jmv-h26r, vulnerable=CVE-2021-31542]`. The comma separates the bracket's items, so each identifier needs a `vulnerable=` of its own.

Only the `ignore` verb silences an advisory. The `allow` verb would keep that advisory's warning and drop the warning about every other advisory, hiding the vulnerabilities you have not assessed. So the language does not offer it.

The reference keeps updating, and Update-time still warns about every other advisory affecting the version it lands on. So a vulnerability found after you wrote the marker still reaches you. Run with `--log-level DEBUG` to see what the marker silenced. To silence an advisory wherever it appears, pass `--ignore-vulnerability` (see [Vulnerable dependencies](#-vulnerable-dependencies)).

#### Setting a risk level

`ignore[vulnerable]` silences every [vulnerability](#-vulnerable-dependencies) warning a reference gets. To keep the warnings but only from a given severity up, give the scope a risk level. `# update-time: ignore[vulnerable<high]` warns about that reference's `high` and `critical` vulnerabilities, and stays quiet about its `low` and `moderate` ones. It is a per-reference `--vulnerability-level high`. Use it for a dependency where you act on `high` and `critical` alone. The level keeps silencing mild advisories published later, so it states a policy rather than a judgement on the advisories the version has today:

```text
django==3.2.0  # update-time: ignore[vulnerable<high] (we act on high and worse for this dependency)
```

The level applies to the reference carrying it, and every other reference in the run keeps the global one. It wins over `--vulnerability-level`, `--vulnerability-level none` included. As with the global level, Update-time warns about a vulnerability whose risk level it cannot read, whatever level is in force. To switch the warning off for one reference, write `ignore[vulnerable]`. `allow[vulnerable>=high]` sets the same level as `ignore[vulnerable<high]`.

### Adopting hash drift

`# update-time: allow[hash-drift]` opts an already-pinned reference *into* adopting its drift, in this run and in every later one (see [Hash drift](#hash-drift)). Update-time then re-pins the reference to the new digest or commit, instead of only warning about the drift. As with an update, Update-time waits until the new digest or commit is older than the [cooldown](#-cooldown). For a branch, it takes the newest commit that is. A registry other than Docker Hub does not date an image, so there it re-pins at once. The global `--allow-hash-drift` flag applies it to every reference in the run.

`ignore[hash-drift]` is the opposite and the default. A reference carrying it keeps its pin exactly as one carrying no marker at all, in a run passing `--allow-hash-drift` as well.

Update-time reports an `allow[hash-drift]` as redundant on a reference whose drift it never adopts (see [The `hash-drift` scope](#the-hash-drift-scope)).

### Keeping a pin floating

`# update-time: allow[floating-pin]` keeps a floating pin as it is: a [floating image tag](#floating-image-tags) such as `python:latest`, a branch such as `actions/checkout@main`, or a tag such as `stable-2024`. Update-time would otherwise pin the image tag to the version and digest it serves, and the branch or tag to the commit it points at (see [GitHub Actions and pre-commit hooks](#github-actions-and-pre-commit-hooks)). Use the marker for a reference you want to follow a channel or a branch, such as an image you rebuild from `latest` on purpose. Run with `--log-level DEBUG` to see what the marker held back. That line names what dropping the marker would pin the reference to. For a floating tag, that is the version the tag resolves to. For a branch, it is the commit the branch points at, named by the version tag at that commit, or else by the branch:

```console
DEBUG Keeping the floating tag python:latest in Dockerfile:2: it resolves to 3.14.7@sha256:… (update-time: allow[floating-pin])
DEBUG Keeping the branch actions/checkout@main in .github/workflows/ci.yml:17: it resolves to 4.3.0@… (update-time: allow[floating-pin])
```

Update-time keeps a reference without a tag the same way. The `DEBUG` line then names the image alone, since the reference has no tag to name.

Update-time still checks a reference kept floating for [hash drift](#hash-drift). It warns about the drift when the image tag now serves another digest than the reference records, or when the branch moved on from the commit the reference records. A reference opted into drift adopts the new digest or commit, while its tag or branch stays as it is.

The global `--allow-floating-pin` flag keeps every reference in the run floating at once. `ignore[floating-pin]` is the opposite and the default, so Update-time pins a reference carrying it exactly as one carrying no marker at all. It pins that reference in a run passing `--allow-floating-pin` as well.

### Bounding an update

A bound lets a reference keep updating, while it blocks the jump you are not ready for. Name the versions the reference may move to, or the level of update it may not make. A bound narrows updates and nothing else: staleness is still measured against the project's newest overall release, and a reference still gets the hash pin of the version it lands on, even when the bound excludes the version the reference is on.

#### Bounding how far a reference may update

`ignore[update]` freezes a reference at its current version. Sometimes you want the middle ground: updates *within a range*, without the jump you're not ready for. For example, keep the `python:3.12` patch releases, and avoid `3.13` until you migrate. Add a [PEP 440](https://peps.python.org/pep-0440/) version specifier directly after `update` inside the brackets, either to allow or ignore updates. `# update-time: allow[update<specifier>]` **keeps only** the updates whose version satisfies the specifier. `# update-time: ignore[update<specifier>]` **drops** the updates whose version satisfies it, and the plain `ignore[update]` is the drop-everything case.

For example, `allow[update<3.13]` keeps a `python` base image on its newest `3.12` release and never crosses into `3.13`:

```dockerfile
# update-time: allow[update<3.13]
FROM python:3.12.1-bookworm-slim
```

The same works inline, for image and action references — here `allow[update==7.*]` keeps Redis on its `7.x` line and `ignore[update>=5]` keeps `checkout` below `v5`:

```yaml
image: redis:7.2  # update-time: allow[update==7.*]
uses: actions/checkout@v4  # update-time: ignore[update>=5]
```

Update-time filters the candidate versions *before* it picks the highest one, so a bounded reference still advances as far as the bound allows. On `3.12.8`, `allow[update<3.13]` still adopts a freshly published `3.12.9`. It just never crosses into `3.13`.

`allow` and `ignore` are complements, which matters for ranges. For a one-sided bound the two are interchangeable — `allow[update<3.13]` and `ignore[update>=3.13]` express the same ceiling. For a *range* they are opposites. With versions `3.13` through `3.16` available, `allow[update>=3.13,<3.15]` keeps the reference *within* `[3.13, 3.15)` and picks `3.14`. `ignore[update>=3.13,<3.15]` *excludes* that range and skips to `3.16`.

Choose the operator deliberately. To keep `3.12` together with its patch releases while blocking `3.13`, use `<3.13`, `==3.12.*`, or `~=3.12.0`. Don't use `<=3.12` if you want to stay on `3.12`. Since `3.12.1 > 3.12` in PEP 440, it also blocks `3.12.1`, which is rarely what "stay on 3.12" means.

#### Bounding by update level

A specifier bound names the version it must not reach, so it goes stale. After you migrate to `3.13`, an `allow[update<3.13]` blocks every update until you rewrite the comment. A level-based bound states the policy instead, holding back or keeping updates by how significant they are:

| Directive | Effect | Complement |
| :-------- | :----- | :--------- |
| `ignore[major-update]` | minor and patch updates only | `allow[minor-update]` |
| `ignore[minor-update]` | patch updates only | `allow[patch-update]` |

Pick whichever verb reads best in context. Unlike a specifier bound, a level-based bound anchors to the currently pinned version on every run. So it ratchets along as the reference advances. `ignore[minor-update]` on `python:3.12.1` blocks `3.13` today, and blocks `3.14` once you migrate the pin to `3.13`. The comment never needs editing:

```dockerfile
# update-time: ignore[minor-update]
FROM python:3.12.1-bookworm-slim
```

The levels are positional, not semantic: they refer to the component's position in the version, not to the project's compatibility promises. Projects may ship breaking changes in releases that bump the *second* component. So "stay on Python 3.12" is `ignore[minor-update]`, although Python 3.13 shipped breaking changes: it removed 19 legacy modules from the standard library. The same caution applies to projects using calendar versioning.

A level bound reads the current version up to the first character that is neither a digit nor a dot. So Jetty's `9.4.53.v20231009` anchors at `9.4.53`, leaving out the date. For a Docker tag, those numbers are its main version, as with specifier bounds, so `python3.12-slim` anchors at `3.12`. The bound does not affect a version embedded in the suffix, such as the `3.23` in `alpine3.23`. A component the current version doesn't have counts as zero, so `ignore[minor-update]` on `node:22` blocks `22.1`.

### Writing a marker

Where a marker goes depends on the format of the file it sits in, and a run at `--log-level DEBUG` reports which markers Update-time read.

#### Where to put a marker

A marker is written in one of three places, and the file's format decides which:

- **Inline**, on the reference's own line (in YAML files, `requirements.txt`, `pyproject.toml`, PEP 723 `# /// script` blocks, `devcontainer.json`, `.python-version`, `pom.xml`, and Sphinx `conf.py` files):

  ```yaml
  image: python:3.12  # update-time: ignore
  ```

  ```text
  humanize==4.15.0  # update-time: ignore
  ```

  ```toml
  dependencies = ["humanize==4.15.0"]  # update-time: ignore
  ```

  ```jsonc
  "ghcr.io/devcontainers/features/node:1": {}  // update-time: ignore
  ```

  ```python
  "https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js",  # update-time: ignore
  ```

  ```xml
  <version>33.0.0-jre</version>  <!-- update-time: ignore -->
  ```

- **On the line directly above** the reference. Dockerfiles need this form, since they don't allow inline comments, and a dependency array takes it as well:

  ```dockerfile
  # update-time: ignore
  FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
  ```

  ```toml
  dependencies = [
      # update-time: ignore
      "humanize==4.15.0",
  ]
  ```

- **In an `update-time` field**, for a reference in a file that can hold no comment. A `package.json` is strict JSON. So its Node engine's marker names the reference it steers instead of sitting beside it, in a field mirroring the file's own structure:

  ```json
  "update-time": { "engines": { "node": "ignore" } }
  ```

The field is the Node engine's alone: every other reference that takes a marker takes it in a comment. Those files are Dockerfiles, Docker Compose and Helm manifests, CircleCI and GitLab CI configs, and GitHub Actions workflows. They also include `.pre-commit-config.yaml` files, `devcontainer.json` files, `requirements.txt` files, `pyproject.toml` files, the `# /// script` blocks in `*.py` files, `.python-version` files, `pom.xml` files, and the jsDelivr URLs in a Sphinx `conf.py`. Use a `#` comment everywhere except in `devcontainer.json` and `pom.xml`. A `devcontainer.json` is JSONC, so its marker goes in a `//` comment. A `pom.xml` is XML, so its marker goes in a `<!-- … -->` comment. An inline marker steers only a reference on its own line, never on the line below it.

Inside a `# /// script` block, every line of the TOML is already commented out, so the marker needs a `#` of its own:

```python
# /// script
# dependencies = [
#     "humanize==4.15.0",  # update-time: ignore
# ]
# ///
```

A marker written with the block's own `#` alone uncomments to `update-time: ignore`, which is not TOML. uv then rejects the script, and Update-time reads no dependency from that block.

A dependency array can put several declarations on one line, or each on a line of its own, so two rules decide which declaration a marker steers. An inline marker steers every dependency declared on its line, so a marker beside `dependencies = ["a==1", "b==2"]` steers both. A marker on the line above steers whatever the line below it declares, wherever in the array that declaration sits. So a marker above the `dependencies = [` line steers nothing, since that line declares no dependency.

A `package.json` dependency takes no marker. Opt one out by declaring an upper bound or an exact version instead (see [npm and pnpm dependencies](#npm-and-pnpm-dependencies)).

#### Confirming a marker was understood

Run with `--log-level DEBUG` to confirm a marker is recognised. Update-time reports a marker it recognised as:

```console
DEBUG Recognised update-time marker ignore[stale] for python in Dockerfile:2
```

That line says the marker was read and understood. No `Recognised` line at all means the marker was not read. The prefix and the verbs are case-sensitive, and a field marker is read by name. So a typo in any of them leaves the reference updated as usual. Update-time logs a typo inside the brackets at `WARNING` as an invalid item instead (see [Invalid markers](#invalid-markers)).

### Seeing what a marker did

Run with `--log-level DEBUG` to see what a marker held back or silenced. Update-time logs every update held back, every pin kept floating, and every warning silenced, each on a line of its own. Each line names the directive it obeyed:

```console
DEBUG Ignoring the staleness warning for python in Dockerfile:2 (update-time: ignore[stale])
```

A line about a warning appears only when the marker actually silenced one, so an `ignore[yanked]` on a version that was never yanked produces none. The line about the update appears whenever a marker Update-time can read holds the update back, whether or not a newer version was available. An unreadable item holds the update back too, but gets no such line: Update-time reports it as invalid instead. A bound does not produce a line either, whatever it blocks, except on a `pom.xml` dependency or plugin whose version Maven updates. There a line names the versions the bound holds back, in Maven's range notation (see [Maven dependencies](#maven-dependencies)):

```console
DEBUG Holding back the versions [34,) of com.google.guava:guava in pom.xml:12 (update-time: ignore[update>=34])
```

### Redundant markers

A directive is redundant when it decides nothing for the reference it sits on: a scope silencing a warning that reference never gets, or a bound narrowing nothing. Update-time reports each one at `WARNING`.

Update-time never reports a bare `# update-time: ignore` as redundant. It names no scope, so a warning would have no directive to name. Update-time does report a scope or item written beside it, so `# update-time: ignore ignore[yanked]` on a Docker image reports the `ignore[yanked]` as redundant, since a registry does not record a yank.

#### The `stale` scope

Staleness is measured against the publication date of a dependency's newest release. In all its forms, Update-time reports the `stale` scope as redundant where it does not measure the reference's staleness. It updates the reference as usual:

| Dependency type | When `stale` is redundant |
| :-------------- | :------------------------ |
| [Python dependencies](#python-dependencies) | on a `pyproject.toml` or inline script metadata dependency that PyPI does not serve a release for |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | never: a `package.json` dependency takes no marker |
| [Maven dependencies](#maven-dependencies) | on an artefact Maven Central does not serve, and on a declaration that leaves its version to the declaration managing it |
| [Node engine version](#node-engine-version-and-python-version) | on an engine that follows the project's Dockerfile, since Update-time checks the base image instead |
| [Python version](#node-engine-version-and-python-version) | on an entry that follows the project's Dockerfile, since Update-time checks the base image instead |
| [Docker images](#docker-images) | on an image on a registry other than Docker Hub, which reports no push date, and on an image the file does not pull |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | never |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | never |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | never |

Update-time does not ask a registry about an image the file does not pull, such as a CircleCI machine-executor image or an image a Docker Compose file builds. Update-time reports a source without publication dates as:

```console
WARNING Redundant update-time directive ignore[stale<90] for ghcr.io/astral-sh/uv in Dockerfile:2: this dependency's source does not report a publication date to measure staleness against
```

#### The `cooldown` scope

Where a cooldown holds nothing back, Update-time reports the `cooldown` scope as redundant:

| Dependency type | When `cooldown` is redundant |
| :-------------- | :--------------------------- |
| [Python dependencies](#python-dependencies) | on a `pyproject.toml` or inline script metadata dependency, since uv takes a cooldown per run rather than per dependency, and on a `requirements.txt` requirement that pins no exact version, since Update-time resolves no update for it |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | never: a `package.json` dependency takes no marker |
| [Maven dependencies](#maven-dependencies) | on a declaration whose version Maven does not update, and on an artefact Maven Central does not serve |
| [Node engine version](#node-engine-version-and-python-version) | on an engine that follows the project's Dockerfile, since the base image's update applied the cooldown |
| [Python version](#node-engine-version-and-python-version) | on an entry that follows the project's Dockerfile, since the base image's update applied the cooldown |
| [Docker images](#docker-images) | on an image on a registry other than Docker Hub, which reports no push date, and on an image the file does not pull |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | on a reference that names a commit SHA, since Update-time never updates a commit |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | on a reference that names a commit SHA, since Update-time never updates a commit |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | never |

A short SHA that a version tag names is the exception. Update-time pins such a SHA as that version, and from the next run on the cooldown governs its updates. Update-time reports a source without publication dates as:

```console
WARNING Redundant update-time directive ignore[cooldown<30] for python in .python-version:2: this dependency's source does not report a publication date to measure a cooldown against
```

It reports the cases particular to a dependency type as:

```console
WARNING Redundant update-time directive ignore[cooldown<30] for humanize in pyproject.toml:12: the package manager applies the cooldown per run rather than per dependency
WARNING Redundant update-time directive ignore[cooldown<30] for humanize in docs/requirements.txt:12: this requirement does not pin a version to update
WARNING Redundant update-time directive ignore[cooldown<30] for actions/checkout in .github/workflows/ci.yml:17: this reference pins a commit rather than a version, so a cooldown holds nothing back
WARNING Redundant update-time directive ignore[cooldown<30] for commons-io:commons-io in child/pom.xml:5: this declaration does not hold its version, so Maven does not update that version here
```

#### The `yanked` scope

A yank can only be observed where the dependency's source reports one, and only for the version a reference is left on (see [Yanked dependencies](#-yanked-dependencies)). Where the scope can never suppress anything, Update-time reports it as redundant:

| Dependency type | When `yanked` is redundant |
| :-------------- | :------------------------- |
| [Python dependencies](#python-dependencies) | on a dependency that pins no exact version, and on a `pyproject.toml` or inline script metadata dependency that PyPI does not serve a release for |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | never: a `package.json` dependency takes no marker |
| [Maven dependencies](#maven-dependencies) | always: Maven Central does not report a withdrawal |
| [Node engine version](#node-engine-version-and-python-version) | always: its source does not record a yank |
| [Python version](#node-engine-version-and-python-version) | always: its source does not record a yank |
| [Docker images](#docker-images) | always: its source does not record a yank |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | always: its source does not record a yank |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | always: its source does not record a yank |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | never |

Update-time reports those cases as:

```console
WARNING Redundant update-time directive ignore[yanked] for python in Dockerfile:2: this dependency's source does not record a yank
WARNING Redundant update-time directive ignore[yanked] for humanize in docs/requirements.txt:12: this requirement does not pin a version to check for a yank
```

#### The `vulnerable` scope

A vulnerability can only be reported where OSV holds advisories for the dependency, and an advisory is matched against a version (see [Vulnerable dependencies](#-vulnerable-dependencies)). Where the scope can never suppress anything, Update-time reports it as redundant in all its forms:

| Dependency type | When `vulnerable` is redundant whatever the version |
| :-------------- | :-------------------------------------------------- |
| [Python dependencies](#python-dependencies) | on a dependency that pins no exact version, and on a `pyproject.toml` or inline script metadata dependency that PyPI does not serve a release for |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | never: a `package.json` dependency takes no marker |
| [Maven dependencies](#maven-dependencies) | on a declaration whose version Maven leaves unresolved, such as a managed version naming a property that the poms do not declare, and on a declaration that leaves its version to the declaration managing it |
| [Node engine version](#node-engine-version-and-python-version) | always: its source does not report vulnerability information |
| [Python version](#node-engine-version-and-python-version) | always: its source does not report vulnerability information |
| [Docker images](#docker-images) | always: its source does not report vulnerability information |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | always: its source does not report vulnerability information |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | always: its source does not report vulnerability information |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | never |

Update-time reports those cases as:

```console
WARNING Redundant update-time directive ignore[vulnerable] for python in Dockerfile:2: this dependency's source does not report vulnerability information
WARNING Redundant update-time directive ignore[vulnerable] for humanize in docs/requirements.txt:12: this requirement does not pin a version to check for a vulnerability
WARNING Redundant update-time directive ignore[vulnerable] for nl.example:internal-lib in pom.xml:12: this dependency does not resolve to a version to check for a vulnerability
```

Update-time reports an `ignore[vulnerable=…]` as redundant when none of the version's vulnerabilities answers to the identifier. An update fixed the vulnerability, or the identifier was mistyped:

```console
WARNING Redundant update-time directive ignore[vulnerable=CVE-2022-28346] for django in docs/requirements.txt:12: version 4.2.0 has no such vulnerability
```

Update-time judges a marker naming several advisories together. It warns only when none of them matches a vulnerability the version has.

Update-time still checks a reference marked `ignore[vulnerable]`, and silences what it finds rather than warning about it. So it reports the marker as redundant for a version that the check clears. A suppression can outlive the vulnerability it was written for, and the check is what tells you it did:

```console
WARNING Redundant update-time directive ignore[vulnerable] for django in docs/requirements.txt:12: version 4.2.0 has no vulnerability
```

Update-time reports an `ignore[vulnerable<high]` as redundant when none of the version's vulnerabilities falls below the level. A level that silences nothing is one the reference no longer needs:

```console
WARNING Redundant update-time directive ignore[vulnerable<high] for django in docs/requirements.txt:12: version 4.2.0 has no vulnerability below high
```

#### The `archived` scope

Archival can only be observed where the dependency's source publishes an archival signal (see [Archived dependencies](#-archived-dependencies)). Where the scope can never suppress anything, Update-time reports it as redundant:

| Dependency type | When `archived` is redundant |
| :-------------- | :--------------------------- |
| [Python dependencies](#python-dependencies) | on a `pyproject.toml` or inline script metadata dependency that PyPI does not serve a release for |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | never: a `package.json` dependency takes no marker |
| [Maven dependencies](#maven-dependencies) | on an artefact Maven Central does not serve, on an artefact whose poms do not name a GitHub repository, and on a declaration that leaves its version to the declaration managing it |
| [Node engine version](#node-engine-version-and-python-version) | always: its source does not publish an archival signal |
| [Python version](#node-engine-version-and-python-version) | always: its source does not publish an archival signal |
| [Docker images](#docker-images) | always: its source does not publish an archival signal |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | never |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | never |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | always: its source does not publish an archival signal |

Update-time reports a source without an archival signal as:

```console
WARNING Redundant update-time directive ignore[archived] for python in Dockerfile:2: this dependency's source does not publish an archival signal
```

The pom of `org.apache.commons:commons-lang3` names `gitbox.apache.org` rather than a GitHub repository, for example (see [Archived dependencies](#archived-dependencies-2)). Update-time reports such an artefact only in a run that checks archival:

```console
WARNING Redundant update-time directive ignore[archived] for org.apache.commons:commons-lang3 in pom.xml:12: Update-time does not find a GitHub repository to check this dependency for archival
```

#### The `floating-pin` scope

An `allow[floating-pin]` on a reference whose pin does not float keeps nothing floating, so Update-time reports it as redundant and updates the reference as usual:

```console
WARNING Redundant update-time directive allow[floating-pin] for python in Dockerfile:2: this reference's pin does not float
```

Update-time never pins a range such as `humanize>=4`, so an `allow[floating-pin]` on one is redundant as well:

```console
WARNING Redundant update-time directive allow[floating-pin] for humanize in docs/requirements.txt:12: this reference names a range, which Update-time never pins
```

On a reference whose pin floats, an `ignore`, or an `ignore[update]` that holds the update back, keeps nothing floating either, since Update-time never pins a reference whose update it holds back. Update-time reports the `allow[floating-pin]` directive as redundant there:

```console
WARNING Redundant update-time directive allow[floating-pin] for python in Dockerfile:2: this reference's update is held back, so it is never pinned
```

Update-time never pins an image the file does not pull, so an `allow[floating-pin]` on one is redundant as well:

```console
WARNING Redundant update-time directive allow[floating-pin] for acme/api in docker-compose.yml:4: the Compose file builds this image, so its tag is never pinned
```

Update-time does not report a floating image tag, a branch, or a tag such as `stable-2024` it cannot pin, since its pin does float. It logs why instead: at `DEBUG` for a floating image tag (see [Floating image tags](#floating-image-tags)), and at `ERROR` for a branch or tag whose commit it cannot fetch (see [GitHub Actions and pre-commit hooks](#github-actions-and-pre-commit-hooks)). Where a floating tag's digest changed, Update-time reports the [hash drift](#hash-drift) instead.

#### The `hash-drift` scope

An `allow[hash-drift]` adopts what a drifted hash pin points at (see [Adopting hash drift](#adopting-hash-drift)). The directive adopts nothing on a reference without a hash pin that could drift, or on one whose drift Update-time never adopts. Update-time reports it as redundant there:

| Dependency type | When `allow[hash-drift]` is redundant |
| :-------------- | :------------------------------------ |
| [Python dependencies](#python-dependencies) | always: a pin names a version, and Update-time writes no hash beside it |
| [npm and pnpm dependencies](#npm-and-pnpm-dependencies) | never: a `package.json` dependency takes no marker |
| [Maven dependencies](#maven-dependencies) | always: a pom names a version, and Maven records an artefact's checksum beside the artefact |
| [Node engine version](#node-engine-version-and-python-version) | always: it names a Node version, not an artefact that could be hashed |
| [Python version](#node-engine-version-and-python-version) | always: it names a Python version, not an artefact that could be hashed |
| [Docker images](#docker-images) | on an image the file does not pull, since Update-time does not ask a registry about its digest |
| [GitHub Actions](#github-actions-and-pre-commit-hooks) | on a reference that names a commit SHA, since a commit cannot drift |
| [Pre-commit hooks](#github-actions-and-pre-commit-hooks) | on a reference that names a commit SHA, since a commit cannot drift |
| [jsDelivr npm URLs](#jsdelivr-npm-urls) | always: Update-time never adopts an integrity hash mismatch |

Update-time reports those cases as:

```console
WARNING Redundant update-time directive allow[hash-drift] for humanize in docs/requirements.txt:12: this reference does not carry a hash pin that could drift
WARNING Redundant update-time directive allow[hash-drift] for clipboard in docs/conf.py:4: Update-time never adopts an integrity hash mismatch
WARNING Redundant update-time directive allow[hash-drift] for acme/api in docker-compose.yml:4: the Compose file builds this image, so its digest is never checked for drift
WARNING Redundant update-time directive allow[hash-drift] for actions/checkout in .github/workflows/ci.yml:17: this reference pins a commit rather than a tag or branch, so it cannot drift
```

On a reference whose drift Update-time would otherwise adopt, an `ignore`, or an `ignore[update]` that holds the update back, leaves the directive adopting nothing as well, since Update-time never rewrites a reference whose update it holds back. Update-time reports the `allow[hash-drift]` directive as redundant there:

```console
WARNING Redundant update-time directive allow[hash-drift] for python in Dockerfile:2: this reference's update is held back, so its drift is never adopted
```

#### A bound

A [bound](#bounding-an-update) can be redundant in two ways:

- Either the bound **never has an effect**, so removing it would change nothing. The current version and every version above it satisfy the bound. Examples are `allow[update>=3.12]` on a `3.12` pin, and `allow[major-update]` on any pin, which allows every update and so says nothing.
- Or the bound **blocks every update**, so it is a frozen `ignore[update]` in disguise. Use `ignore[update]` instead if you intend the freeze. No version above the current one satisfies the bound. Examples are `ignore[update>=3.12]` on a `3.12` pin, and `ignore[patch-update]` on any pin.

On a `requirements.txt` requirement that pins no exact version, Update-time reports every bound as redundant, `ignore[update]` included, since it resolves no update to bound.

On a `pyproject.toml` or inline script metadata dependency, Update-time reports every bound as redundant, since uv answers with one version rather than the candidates a bound would narrow. It reports an `ignore[update]` as redundant only for a dependency that pins no exact version.

On a GitHub Action or pre-commit hook that names a branch, a tag such as `stable-2024`, or a commit SHA, Update-time reports every bound as redundant, since none of them names a version to bound. In the run that pins a branch, a tag, or a short SHA to a version tag, Update-time judges the bound at that version instead. It never pins a full SHA, so a bound on one is always redundant.

On a `pom.xml` dependency or plugin whose version Maven does not update, Update-time reports every bound as redundant, `ignore[update]` included (see [Markers](#markers-2)).

Update-time reports those bounds as:

```console
WARNING Redundant update-time directive allow[update<5] for humanize in pyproject.toml:12: the package manager resolves this dependency's version, so a bound decides nothing
WARNING Redundant update-time directive ignore[update] for humanize in pyproject.toml:12: the package manager resolves this dependency's version
WARNING Redundant update-time directive allow[update<5] for humanize in docs/requirements.txt:12: this requirement does not pin a version to update
WARNING Redundant update-time directive allow[update<5] for actions/checkout in .github/workflows/ci.yml:17: this reference pins a commit rather than a version, so a bound decides nothing
WARNING Redundant update-time directive allow[update<6] for actions/setup-python in .github/workflows/ci.yml:18: this reference follows a branch or a floating tag rather than a version, so a bound decides nothing
WARNING Redundant update-time directive ignore[major-update] for commons-io:commons-io in child/pom.xml:5: this declaration does not hold its version, so Maven does not update that version here
WARNING Redundant update-time directive allow[update<4] for org.apache.maven.plugins:maven-surefire-plugin in pom.xml:30: Maven does not update this plugin's version
```

### Incorrect markers

A threshold, a cooldown, and a risk level are each set by a comparison, and either verb can write it. Written the wrong way round, the comparison sets nothing. Update-time logs it at `WARNING` as incorrect and holds nothing back, so the reference updates as usual and the global value applies to it.

Inverting a threshold's operator would warn while a release is fresh, and go quiet once it is old. So neither `allow[stale<90]` nor `ignore[stale>=90]` sets a threshold:

```console
WARNING Incorrect 'stale>=90' in the update-time marker for python in Dockerfile:2: this comparison warns while a release is fresh and goes quiet once it is old, so it sets no threshold
```

Inverting a cooldown's operator would adopt a release only while it is fresh, and hold it back once it is old. So neither `allow[cooldown<30]` nor `ignore[cooldown>=30]` sets a cooldown:

```console
WARNING Incorrect 'cooldown>=30' in the update-time marker for python in Dockerfile:2: this comparison adopts a release only while it is fresh and holds it back once it is old, so it sets no cooldown
```

Inverting a risk level's operator would warn about the mild vulnerabilities, and stay quiet about the severe ones. So neither `allow[vulnerable<high]` nor `ignore[vulnerable>=high]` sets a level:

```console
WARNING Incorrect 'vulnerable>=high' in the update-time marker for django in docs/requirements.txt:12: this comparison warns about the mild vulnerabilities and stays quiet about the severe ones, so it sets no risk level
```

### Invalid markers

A scope Update-time does not recognise — a mistyped `ignore[stlae]`, say — is logged at `WARNING` as an invalid item:

```console
WARNING Invalid 'stlae' in the update-time marker for python in Dockerfile:2; leaving the reference unchanged
```

Update-time leaves the reference as it is, because an item it cannot read may have been meant to bound the update, or to hold it back as an `ignore[update]` does. Applying an update would be guessing. The checks still run, since Update-time never reads that item as silencing a warning. An unreadable marker holds back what Update-time would write, never what it would tell you. Every item beside it that Update-time does read applies as written, so `ignore[cooldwn<30, stale]` still silences the staleness warning.

A day count must be a whole number of days. So Update-time reports `ignore[stale<-5]`, `ignore[stale>=1.5]`, `ignore[cooldown<-5]`, and `ignore[cooldown<1.5]` as invalid.

You can read a bare `ignore[cooldown]` in two ways: adopt at once, or never adopt at all. Rather than guess, Update-time reports it as invalid. It reports `allow[cooldown]` the same way. Write `allow[cooldown>=0]` to adopt at once, and `ignore[update]` to freeze the reference.

A risk level must be one of `low`, `moderate`, `high`, and `critical`, spelled in lower case. So Update-time reports `ignore[vulnerable<hgih]` as invalid. `none` is a value for `--vulnerability-level` rather than a level, so Update-time reports it as invalid too. An advisory is named by `ignore` alone, so `allow[vulnerable=GHSA-…]` is invalid as well. So is a second advisory written without a `vulnerable=` of its own, as in `ignore[vulnerable=GHSA-…,CVE-…]`.

Update-time reports an `update-time` field it cannot read as an invalid item too. It reads a field whole rather than item by item. So an unreadable field drops every directive it carries, where an unreadable bracket item leaves the items beside it standing. The warning names where the marker would sit rather than the value that is wrong. A field of the wrong shape holds no marker to quote:

```console
WARNING Invalid 'update-time.engines.node' in the update-time marker for node in package.json:3; leaving the reference unchanged
```

## 📖 Details per dependency type

For each dependency type, this chapter answers the same questions: what files, dependencies, and versions Update-time updates. It also says how pinning, the cooldown, staleness, yanks, vulnerabilities, and archival are handled, and where a marker can go. GitHub Actions and pre-commit hooks behave alike, so they share a section. So do the Node engine version and the Python version.

### Python dependencies

#### What files are updated?

Update-time discovers Python files containing requirements by name, case-sensitively. Those are `pyproject.toml`, `requirements.txt`, `requirements-<purpose>.txt` and `<purpose>-requirements.txt` (for example `requirements-dev.txt` and `dev-requirements.txt`), and any `.txt` file in a `requirements/` directory. It touches no unrelated file, such as `constraints.txt` or `requirements.in`.

Update-time also updates any `*.py` file that carries a [PEP 723](https://peps.python.org/pep-0723/) inline script metadata block. That is a `# /// script … # ///` comment block declaring the standalone script's dependencies. A `*.py` file without such a block stays untouched and never invokes uv.

Update-time skips compiled or hash-pinned requirements files entirely, such as a `requirements.txt` generated by [pip-tools](https://github.com/jazzband/pip-tools) or `uv pip compile`. Bumping a single pin without recompiling its transitive dependencies and hashes would corrupt the file. Regenerate these with your package manager instead. Update-time recognises compiled or hash-pinned files by their contents, an autogenerated header or `--hash=` lines. It also recognises them by a sibling `.in` file.

#### What dependencies are updated?

Update-time reads the dependency arrays a `pyproject.toml` declares. Those are the `dependencies` array under `[project]`, one array per extra under `optional-dependencies`, and one per group under `dependency-groups`. It also reads uv's legacy `[tool.uv] dev-dependencies`, and the `requires` array under `[build-system]`. It leaves every other string the file holds alone, `[tool.uv] constraint-dependencies` and `override-dependencies` among them.

Update-time cannot update individual git, VCS, and URL dependencies, in a `requirements.txt` or a `pyproject.toml`. Examples are `git+https://github.com/org/repo.git@v8.0.3.0`, direct URLs, and `-e`/editable installs. Update them manually.

Update-time updates a dependency uv resolves through a `[tool.uv] sources` entry like any dependency in the file. Such an entry names a path, a workspace member, or another index. Here too uv resolves the dependency, and Update-time only writes the version uv reports. PyPI does not serve a release for such a dependency, nor for one pointing at a URL or a git repository. So none of the warnings below applies to either.

In a PEP 723 inline script metadata block, Update-time updates only the pins in the `dependencies` array. It leaves the `requires-python` value and any other inline-metadata field untouched.

#### What versions are updated?

Update-time rewrites a dependency's declaration when it pins one exact version with `==`. What the declaration spells around the pin makes no difference. Update-time accepts an extra, an environment marker, and spaces around the operator, and keeps them as written when it rewrites the version. So it rewrites `django[argon2]==3.2.0`, `humanize==4.15.0; python_version < "3.13"`, and `humanize == 4.15.0` each to the new version.

Update-time leaves every other specifier untouched, so you can pin a maximum version to opt a dependency out of automatic updates. A `==` that names more than one version pins none either: `humanize==4.15.*` names a range, `humanize===nightly` is an arbitrary equality, and `humanize==4.15.0,!=4.15.1` combines two specifiers. Update-time leaves each of them alone.

Update-time still reports a new version for a dependency that pins no exact version, both in a `pyproject.toml` and in an inline script metadata block. In a `pyproject.toml` uv also applies it: uv resolves the specifier and records the version it picks in the `uv.lock` file. So `humanize>=4` moves from one release to the next without its line changing. A script has no lock file, so there Update-time reports the new version and nothing more. In a `requirements.txt` it reports no new version, because it resolves none for a requirement that pins no exact version. It still checks that requirement's package for staleness and archival.

For a `requirements.txt` pin, Update-time reads from PyPI whether each newer release was yanked and when it was published. When PyPI does not serve that for a release, Update-time stays on the version the pin names rather than trying the versions in between. It logs why at `ERROR`:

```console
ERROR Could not fetch from PyPI the metadata of humanize 4.16.0 (HTTP 503), so its yank state and publication date are unknown; humanize stays on 4.15.0 rather than trying the versions in between
```

#### Pinning

Update-time adds no hash pin to a Python dependency. A `requirements.txt` pin carries one as a `--hash=` line, which has to hold for the file's transitive dependencies too. So Update-time skips a file that already has them entirely, rather than rewriting it partly. Dependencies in `pyproject.toml` are locked by uv, which records each distribution's hash in the `uv.lock` file it maintains. A PEP 723 script has no lock file, so its pins carry no hash either.

#### Cooldown

For a `requirements.txt` pin, Update-time enforces the cooldown itself, against the release's publication date on PyPI.

For `pyproject.toml` dependencies, Update-time applies the cooldown through uv's `exclude-newer` setting. It writes the setting into the workspace root's `pyproject.toml` under `[tool.uv]`. The value is a relative one such as `exclude-newer = "7 days"`, tagged with a `managed by Update-time` comment. The setting then applies to every uv command in the project (`uv lock`, `uv add`, CI), not just to Update-time.

Update-time keeps its own tagged value in step with `--cooldown`. An `exclude-newer` without the `managed by Update-time` comment stays as it is, and so does a `UV_EXCLUDE_NEWER` environment variable. Remove the comment to take ownership of the line.

For inline script metadata, Update-time also applies the cooldown through uv's `exclude-newer`. It passes the value to `uv tree` on the command line rather than persisting it, since a standalone script has no lockfile to keep reproducible. Update-time derives the cutoff from `--cooldown` on every run, so, unlike `pyproject.toml`, it writes nothing into the `# /// script` block.

#### Stale dependencies

Update-time checks each pin a Python file declares against the newest release of its package on PyPI and reports the stale ones. It checks a requirement that pins no exact version as well, wherever the file declares it. The package name alone is enough to find the newest release.

Update-time skips a package published only as `.egg` files, since PyPI no longer accepts files named that way.

#### Yanked dependencies

Update-time checks each exact pin a Python file declares against [PEP 592](https://peps.python.org/pep-0592/)'s yank metadata on PyPI. It skips a yanked release when picking a new version. The version checked is the one the file holds when the run ends.

Update-time reports a `requirements.txt` pin the run leaves on a yanked release, unless an `ignore[yanked]` marker silences that warning. It reports a `pyproject.toml` or inline script metadata pin left on a yanked release the same way. It does not check a dependency those files declare without an exact pin. A yank is about the version a reference is left on, and such a declaration names none.

#### Vulnerable dependencies

Update-time checks each exact pin a Python file declares against OSV's PyPI advisories.

Update-time reports a vulnerable `requirements.txt` pin, unless an `ignore[vulnerable]` marker silences that warning. It reports a vulnerable `pyproject.toml` or inline script metadata pin the same way. It does not check a dependency those files declare without an exact pin either. An advisory is matched against a version, and such a declaration names none.

#### Archived dependencies

PyPI publishes a project status. Update-time reads it for each dependency a Python file declares, whether or not the dependency pins an exact version. Archival is a fact about the project, so the package's name alone is enough to find that status.

Update-time reports a `requirements.txt` requirement whose project is archived, unless an `ignore[archived]` marker silences that warning. It reports a `pyproject.toml` or inline script metadata dependency the same way.

#### Markers

Write a marker for a `requirements.txt` pin inline, on the pin's own line. A `pyproject.toml` dependency and an inline script metadata dependency take one in either placement. See [Controlling updates and warnings per reference](#-controlling-updates-and-warnings-per-reference) for the directives and where they go.

An `ignore[update]` holds the dependency's update back, so Update-time leaves that pin as the file wrote it and reports no new version for it. The pin is what constrains uv, so a pin Update-time declines to rewrite is the version uv keeps resolving. A dependency that pins no exact version has no pin to freeze, since uv both resolves and records its version. A name a file declares twice is steered per declaration, so a marker steers the line it sits on and leaves the other line alone.

Update-time checks a requirement that pins no exact version for staleness and archival, so such a requirement can carry a marker as well: `humanize>=4  # update-time: ignore[stale<1095]` warns once that package's newest release is more than three years old.

Update-time reports every warning scope as redundant on a dependency PyPI does not serve a release for: one pointing at a URL or a git repository, or one uv resolves through a `[tool.uv] sources` entry.

### npm and pnpm dependencies

#### What files are updated?

Update-time looks for `package.json` files recursively from the starting path. The accompanying lock file is updated as well: `package-lock.json` for npm, `pnpm-lock.yaml` for pnpm.

#### What dependencies are updated?

Update-time delegates updating Node dependencies to the package manager that manages the `package.json`. If that is npm, it runs `npm update --save --include=dev`. If it is pnpm, it runs `pnpm update`.

#### What versions are updated?

Because Update-time delegates updating Node dependencies to a package manager, that package manager also chooses the version. Both npm and pnpm update a dependency to the newest version that satisfies the range declared in the `package.json`. So a dependency declared as `"react": "^17.0.0"` receives `17.x` updates, but never moves to `18`, and one pinned to an exact version stays where it is. See the documentation of [npm update](https://docs.npmjs.com/cli/v12/commands/npm-update) and [pnpm update](https://pnpm.io/cli/update) for the finer points of how each manager resolves versions.

#### Pinning

Update-time adds no hash pin to an npm or pnpm dependency. The integrity hash of each resolved package lives in the lock file, which npm and pnpm maintain themselves and Update-time updates by running them.

#### Cooldown

For npm, Update-time passes the cooldown via npm's `min-release-age` option, also measured in days, which npm added in 11.10.0. Older npm versions ignore the option, so updates still run but without a cooldown. If your project already configures a cooldown in its `.npmrc` (`min-release-age` or `before`), Update-time leaves that in place instead of overriding it.

For pnpm, Update-time passes the cooldown via pnpm's `minimumReleaseAge` setting, converting the value to minutes (pnpm measures the age in minutes rather than days). If your project already configures `minimumReleaseAge` (in `pnpm-workspace.yaml`), Update-time leaves that in place instead of overriding it.

#### Stale dependencies

Update-time checks each dependency against its newest release on the npm registry, and reports the stale ones. It skips dependencies given as git, file, link, workspace, alias, or GitHub-shorthand references, since they don't resolve to a registry release.

#### Yanked dependencies

npm and pnpm handle deprecated versions themselves when they resolve an update, so Update-time does not check a `package.json` dependency for one.

#### Vulnerable dependencies

Update-time does not check a `package.json` dependency. It declares a range rather than a version, and the lock file records the version that range resolves to. Update-time does not read that lock file.

#### Archived dependencies

The npm registry does not publish an archival signal, so Update-time does not check a `package.json` dependency. What npmjs.com shows as a deprecated package is a deprecation on the release its `latest` tag points at. That says something about the release rather than about the project.

#### Markers

A `package.json` dependency takes no marker, because npm and pnpm update it rather than Update-time rewriting its lines. Opt one out by declaring an upper bound or an exact version instead.

### Maven dependencies

#### What files are updated?

Update-time looks for `pom.xml` files recursively from the starting path.

#### What dependencies are updated?

Update-time reads every `<dependency>` element the pom declares, wherever it sits: under `<dependencies>`, under `<dependencyManagement>`, in a `<profile>`, and in a plugin's own `<dependencies>`. A pom can leave a dependency's `<version>` out, when a parent pom, an imported BOM, or its own `<dependencyManagement>` manages that version. Update-time does not update such a dependency, since the pom does not hold a version to advance. Update-time still checks such a dependency, as the sections below describe.

Update-time also reads every `<plugin>` element the pom declares: under `<plugins>`, under `<pluginManagement>`, and in a `<profile>`. It checks each plugin as it checks a dependency.

Update-time warns once about a version that a scanned pom manages, at the declaration managing it. It skips the checks for each declaration that leaves its `<version>` to that pom, whether in the managing pom itself or in a module inheriting from it. A module can override a property that the managed `<version>` names, and so resolve another version. Update-time then checks the module's declaration at that version. It also checks the module's declaration where the managing pom leaves that property to a pom above it. A pom outside the scan can manage the version as well, such as a released parent pom or an imported BOM. Update-time then checks each declaration that leaves its `<version>` out.

Update-time delegates updating Maven dependencies to Maven. The Maven it runs is the project's own `./mvnw` wrapper when one sits beside the pom, and the `mvn` on your path where the project ships none. In each pom's own directory, it runs two goals of the [versions plugin](https://www.mojohaus.org/versions/versions-maven-plugin/). The `use-latest-releases` goal advances a dependency whose `<version>` element holds a version. The `update-properties` goal advances a dependency or plugin whose `<version>` element names one of the pom's properties, by advancing that property. Update-time reports the new version of each dependency and plugin that Maven moved, so two artefacts that name the same property get a report each. Update-time reports each new version under the group and artifact Maven resolves.

The `use-latest-releases` goal leaves a plugin alone, so a plugin whose `<version>` element holds a version stays on that version. Update-time reports no new version for such a plugin.

Before the versions plugin's goals, Update-time runs the `effective-pom` goal of the [help plugin](https://maven.apache.org/plugins/maven-help-plugin/), which changes nothing. That goal writes the effective pom: the pom as Maven builds it, with what its parent poms and imported BOMs contribute filled in. Update-time takes two things from the effective pom:

- The pom can leave a group or artifact to its parent. It then names a property its parent declares, or it names `${project.groupId}` while it inherits its group. The cooldown and every check then name the artefact Maven resolves.
- The pom can leave a version to another pom or to Maven. It then names a property its parent declares, or it omits the `<version>` element. A dependency without one takes its version from the parent's `<dependencyManagement>` or from an imported BOM. A plugin without one takes it from the parent's `<pluginManagement>`, from the release Maven binds to its build lifecycle, or else from the newest release. The vulnerability check then checks the version Maven resolves.

Update-time runs the `effective-pom` goal and the versions plugin's goals in one Maven run. A pom that leaves a group or artifact to its parent gets the `effective-pom` goal in a Maven run of its own, so the cooldown's rule set can name the artefact Maven resolves. Update-time names the release of both plugins in every goal it runs, so which release runs is Update-time's decision rather than the scanned project's.

Without an effective pom to read, the cooldown holds nothing back for a group or artifact the pom leaves to its parent. The checks then pass that dependency or plugin over, and the vulnerability check passes over a version the pom leaves to another pom or to Maven. That happens in three cases:

1. Maven cannot build the pom. It then does not write an effective pom, and Update-time reports Maven's error.
2. The project sets the help plugin's `<output>`, and Maven then writes the effective pom to that path. Maven reports the path in a line of its own, where Update-time reads it. A `--quiet` in the project's Maven config hides that line, so Update-time cannot find the effective pom, and warns:

   ```console
   WARNING Could not read the effective pom of pom.xml, so the cooldown and the checks passed over any group or artifact the pom leaves to its parent, and the vulnerability check passed over any version the pom leaves to another pom or to Maven; perhaps a --quiet in the project's Maven config hid where Maven wrote it
   ```

3. The project sets the help plugin's `<verbose>` to false. The effective pom then lacks its input locations: the pom and the line that declare each element. Update-time cannot match such an effective pom to the pom's declarations, and warns:

   ```console
   WARNING The effective pom of pom.xml lacks the input locations Update-time reads, so the cooldown and the checks passed over any group or artifact the pom leaves to its parent, and the vulnerability check passed over any version the pom leaves to another pom or to Maven; the project's help plugin configuration sets verbose to false
   ```

Update-time checks the managing pom's own declarations only where it can read that pom back after Maven's run. Maven can leave that pom unparsable, or holding another number of declarations, and Update-time then reports an error. A module still skips the declarations that leave their version to that pom, unless the managing pom no longer parses by the time the module's run reads it. So those declarations can go unchecked in that run, and the next run checks them.

#### What versions are updated?

The versions plugin picks the newest release of each dependency and plugin it advances, and it never adopts a snapshot. The plugin reads every other version as a release, a release candidate included. So Update-time excludes the pre-releases, which the plugin would otherwise adopt.

#### Pinning

Update-time adds no hash pin to a Maven dependency, since a pom has nowhere to hold one: a `<version>` element names a version. The checksum of an artefact lives beside that artefact, in the repository Maven downloads it from.

#### Cooldown

The versions plugin does not filter releases by age, so Update-time names the versions the plugin may not adopt instead. It reads each artefact's publication dates from [Maven Central](https://repo1.maven.org/maven2/), and writes every version published inside the cooldown into a rule set the plugin reads. The plugin then picks the newest version left. Both goals honour the rule set, so it reaches both kinds of dependency: the one whose `<version>` holds a version, and the one whose `<version>` names a property. The rule set names the pom's plugins too, so a property that versions a plugin is held back like any other.

Maven Central does not serve an artefact that a project resolves from a mirror or a private repository. Update-time then cannot read its publication date, so it holds nothing back for that artefact. It reports a marker's `cooldown` on that artefact as redundant.

The rule set names an artefact whose group or artifact the pom leaves to its parent as Maven resolves it, and holds back its versions like any other artefact's.

A marker can set a cooldown of its own, and the rule set then holds back the versions published inside that cooldown. A marker that ignores the update holds back every version of its artefact instead. A bound holds back the versions it drops, beside the ones inside the cooldown (see [Markers](#markers-2)).

#### Stale dependencies

Update-time checks each dependency and plugin against the newest release Maven Central lists for it.

Update-time asks Maven Central about an artefact by that artefact's `groupId:artifactId` coordinates alone. So it checks a dependency whose `<version>` names a property its parent declares. It also checks a dependency or plugin that leaves its `<version>` out, unless Update-time checks the declaration managing that version in its place. For a group or artifact the pom leaves to its parent, it asks about the artefact Maven resolves.

Maven Central lists nothing for an artefact a project resolves from a mirror or a private repository. Update-time then has nothing to measure staleness against, and reports a marker's `stale` scope on that artefact as redundant.

#### Yanked dependencies

Maven Central does not report a withdrawal, so Update-time never warns that a Maven dependency is yanked.

#### Vulnerable dependencies

Update-time checks the pom's dependencies and plugins against OSV's Maven advisories. Where the pom holds the version of a dependency or plugin itself, Update-time checks the version the pom holds once Maven has run. The pom holds that version in the declaration's own `<version>` element, in one of its own properties, or in its own `<dependencyManagement>` or `<pluginManagement>`. So the run never reports a vulnerability it updated such a dependency or plugin away from.

For a version the pom leaves to another pom or to Maven, Update-time checks the version the effective pom gives the dependency or plugin. Maven wrote that effective pom before it updated the pom. Update-time skips a dependency or plugin that leaves its `<version>` out where it checks the declaration managing that version in its place.

In two cases, Update-time warns about a version the same run updates away from. The scan can reach a child pom before its parent pom, and the parent's own Maven run can then still move that version. And the Maven run over a pom can move the version of a BOM that pom imports itself, while the effective pom lists the versions the BOM held before. In both cases, the next run checks the version the first run moved to.

Update-time does not check a dependency or plugin whose `<version>` element is empty, since OSV needs a version to match an advisory to. It does not check a dependency or plugin whose version Maven leaves unresolved either, such as one in `<dependencyManagement>` naming an undeclared property.

Update-time reports a vulnerable dependency or plugin, unless an `ignore[vulnerable]` marker silences that warning.

#### Archived dependencies

Maven Central does not publish an archival signal of its own. So Update-time looks up the repository of each dependency and plugin on GitHub, and warns when GitHub reports that repository as archived. For a dependency or plugin whose group or artifact the pom leaves to its parent, it looks up the artefact Maven resolves, as the staleness check does.

Update-time finds the repository in the pom that Maven Central serves beside the artefact's newest release. It reads the newest release's pom whatever version the reference pins, because archival is a fact about the project rather than about one of its versions. Update-time looks in these places, in this order, and takes the first one that names a GitHub repository:

1. The `<url>`, `<connection>`, and `<developerConnection>` of the `<scm>` element of the newest release's pom. That element names where the project's source lives.
2. The `<url>` element of the newest release's pom, although it names the project's website more often than its repository. Update-time tries it after `<scm>`, because a module's `<url>` may name the repository of the larger project the module is part of. The `<url>` of `com.fasterxml.jackson.core:jackson-databind` names `FasterXML/jackson`, while its `<scm>` names `FasterXML/jackson-databind`.
3. The `<scm>` and `<url>` of the parent pom that the `<parent>` element of the newest release's pom names. A project that consists of several modules often names its repository once, in the pom that all its modules inherit from. Update-time reads the parent pom only when the `<scm>` element of the newest release's pom does not name a URL.

Update-time reads one parent pom, and does not read the parent pom's own parent. The poms higher up usually belong to an organisation rather than to a project. Every Apache project inherits from `org.apache:apache`, for example, which names the repository `apache/maven-apache-parent`. For the same reason, Update-time does not read the parent pom when the `<scm>` element of the newest release's pom names a repository outside GitHub. That parent is often an organisation's pom, too.

Update-time warns only where it finds a GitHub repository to ask about. The newest release's pom and its parent pom may leave the repository unnamed. They may also name a host other than GitHub, as `org.apache.commons:commons-lang3` names `gitbox.apache.org`. And Maven Central may not serve a pom at all, for an artefact a project resolves from a mirror or a private repository. Update-time warns about a pom it cannot fetch or cannot parse. Where the poms it reads do not name a GitHub repository, it reports a marker's `archived` scope as redundant.

Update-time reports a dependency or plugin whose repository is archived, unless an `ignore[archived]` marker silences that warning.

#### Markers

A Maven dependency or plugin takes a marker in an XML comment at the end of its own line. Update-time does not read a marker on the line above. The dependency's or plugin's own line is the line Update-time reports it at:

- The `<version>` line, for a declaration whose `<version>` holds a version:

  ```xml
  <version>33.0.0-jre</version>  <!-- update-time: ignore[update] -->
  ```

- The property's line, for a declaration whose `<version>` names one of the pom's own properties. Every dependency and plugin naming that property follows the marker:

  ```xml
  <guava.version>33.0.0-jre</guava.version>  <!-- update-time: ignore[stale] -->
  ```

- The line of the `<dependency>` or `<plugin>` element, for a declaration without a `<version>`:

  ```xml
  <dependency>  <!-- update-time: ignore[archived] -->
  ```

The `stale`, `archived`, and `vulnerable` scopes steer the checks as they do elsewhere. Where a scanned pom manages a declaration's version, Update-time checks that version at the managing declaration. So those scopes go on the managing declaration's line. Update-time reports them as redundant on a declaration that leaves its version to the managing declaration:

```console
WARNING Redundant update-time directive ignore[stale] for commons-io:commons-io in child/pom.xml:5: Update-time checks this dependency at the declaration that manages its version
```

Update-time reports the `cooldown`, `stale`, and `archived` scopes as redundant for a dependency or plugin that Maven Central does not serve. A project resolves such an artefact from a mirror or a private repository, and Update-time reads publication dates and poms from Maven Central alone. The `vulnerable` scope still applies, since OSV holds the advisories:

```console
WARNING Redundant update-time directive ignore[cooldown<30] for nl.example:internal-lib in pom.xml:12: Maven Central does not serve this dependency
```

A marker steers the update only where Maven updates the version. Maven updates a dependency's version where the declaration's `<version>` element or one of the pom's own properties holds it. It updates a plugin's version only where one of the pom's own properties holds it, since the `use-latest-releases` goal leaves plugins alone. Maven does not update a version that a declaration leaves to a parent pom, an imported BOM, or its pom's `<dependencyManagement>` or `<pluginManagement>`. On a declaration whose version Maven does not update, Update-time reports an `ignore[update]`, a bound, and a `cooldown` as redundant (see [Redundant markers](#redundant-markers)).

Where Maven updates the version, Update-time hands Maven a rule per artefact. An `ignore`, an `ignore[update]`, and an item Update-time cannot read each hold back every version of the artefact. Update-time reports the first two at `DEBUG` as holding the update back, and warns about the unreadable item as invalid. A `cooldown` holds back the versions published inside the cooldown it sets.

A bound holds back the versions it drops, which the rule names as Maven ranges. Update-time reports those ranges at `DEBUG` (see [Seeing what a marker did](#seeing-what-a-marker-did)). A bound reads a version up to the first character that is neither a digit nor a dot, so `allow[update<=32.1.3]` keeps guava's `32.1.3-jre`. A bound that names a version other than a release, such as `34.1rc1`, holds back every version. So does a level bound on a version that does not hold a number, such as `RELEASE`.

A pom can declare an artefact more than once, and Update-time then hands Maven one rule for all those declarations. The rule holds back what each declaration whose version Maven updates holds back. So a marker on one of them that holds back every version holds back every declaration of the artefact in the pom. Without such a marker, the rule holds back the versions each declaration's bound drops, and the versions published inside the longest cooldown among the declarations. A declaration whose marker does not set a cooldown has the run's cooldown.

### GitHub Actions and pre-commit hooks

Both resolve their versions from the same source: a GitHub repository's releases and tags. So they behave the same way, except where the file format differs.

#### What files are updated?

For GitHub Actions, Update-time looks for `*.yml` and `*.yaml` files under the `.github/` folder, recursively. That covers both workflow files (`.github/workflows/*.yml`) and composite action definitions.

For pre-commit hooks, it looks for `.pre-commit-config.yaml` files, recursively from the starting path. Pre-commit reads the file at the repository root, but a monorepo can carry one per sub-project, so Update-time updates every one it finds.

#### What dependencies are updated?

In a workflow or action definition, the actions in the `uses:` references. A local action, without an `@`, does not name a repository, so Update-time leaves it alone.

In a `.pre-commit-config.yaml`, the `rev:` of each hook repository hosted on GitHub. A `repo: local` or `repo: meta` entry has no `rev:`, so Update-time leaves it untouched, as it leaves a repository hosted outside GitHub untouched.

A `uses:` or a `rev:` can also name a branch, such as `@main` or `rev: main`, a tag such as `stable-2024`, or a short commit SHA. Update-time pins such a reference rather than updating it.

#### What versions are updated?

Update-time moves a reference given as a version tag to the latest version. Such a tag is `@v4` or `@v4.1.1` for an action, and `v4.5.0` for a `rev:`. It also moves a reference already pinned to a commit SHA with a version comment, such as `@<sha> # v4.1.1` or `rev: <sha>  # frozen: v4.5.0`.

Often, the latest version is the latest GitHub release. But a version that was tagged without being published as a release counts too. So Update-time still updates a repository that only tags its versions, or whose releases stopped while tagging continued.

#### Pinning

Update-time pins an action referenced by version tag only to the commit SHA of the latest version, and adds that version's tag as a trailing comment. So `uses: actions/checkout@v4` becomes `uses: actions/checkout@<sha> # v4.1.1`. When the run keeps the reference on `v4`, and other version tags do not point at its commit, the comment names `v4` itself.

Update-time pins a `rev:` referenced by version tag only to the commit SHA the same way. Update-time writes the version's tag in pre-commit's own `# frozen:` comment, so `rev: v4.5.0` becomes `rev: <sha>  # frozen: v4.5.0`.

Update-time pins an action referenced by a branch to the commit the branch points at. When a version tag points at that commit, the comment names it, so `uses: actions/checkout@main` becomes `uses: actions/checkout@<sha> # v4.3.0`. When several do, the comment names the highest, spelled most precisely: `v4.3.0` rather than `v4.3` or `v4`. A pre-release tag does not count. When no version tag points at the commit, the comment names the branch, so `@main` becomes `@<sha> # main`. Update-time pins a `rev:` that names a branch the same way, in a `# frozen:` comment. It pins a tag such as `stable-2024` the way it pins a branch.

Update-time pins a short commit SHA to the full SHA, so `rev: 3e8a870` becomes `rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c`. When a version tag points at the commit, the comment names that tag, as for a branch: `rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c  # frozen: v6.0.0`. It leaves a full commit SHA without a comment alone, in a `uses:` and a `rev:` alike. So does a full commit SHA whose comment does not name a version, tag, or branch, such as `# pinned by hand` or `# ratchet:actions/checkout@v4`.

Update-time leaves a reference naming a branch, a tag such as `stable-2024`, or a short commit SHA as it is when it cannot fetch the commit the reference points at. It logs why at `ERROR`:

```console
ERROR Could not fetch the commit of the ref actions/checkout@main in .github/workflows/ci.yml:17, so it was left as it is (HTTP 403, API rate limit exceeded)
```

The line names a short commit SHA as one. It calls any other reference a branch or a tag, or a ref where GitHub refuses to say which of the two it is.

When the commit can be fetched but the repository's tags cannot be listed, Update-time pins the reference as the branch. When a later run can list the tags, it names the version tag.

While the comment names the branch, every later run compares the branch's newest commit older than the [cooldown](#-cooldown) with the commit the reference is pinned to, as [Hash drift](#hash-drift) describes. Update-time never moves the reference back. The first pin takes the commit the branch points at, however fresh. Until that commit is older than the cooldown, the branch's newest commit older than the cooldown is an earlier one, and Update-time leaves the reference as it is. `@<sha> # main` becomes `@<sha> # v4.3.1` once a version tag points at the pinned commit, provided the branch has not moved on from the pinned commit. Where Update-time adopts a moved branch's new commit, the comment names the version tag pointing at that commit, or the branch where none does.

When GitHub cannot compare the pinned commit with the newest one, Update-time cannot tell whether the branch moved on, and leaves the reference as it is. It logs why at `ERROR`:

```console
ERROR Could not check branch drift for actions/checkout@main in .github/workflows/ci.yml:17: GitHub did not compare the pinned commit with …, the branch's newest commit older than the cooldown (HTTP 403, API rate limit exceeded); the pin was left unchanged
```

Update-time reads the branch's newest 500 commits at most. When the branch's newest commit older than the cooldown lies deeper, Update-time leaves the reference as it is and logs why at `ERROR`:

```console
ERROR Could not fetch the commit of the branch actions/checkout@main in .github/workflows/ci.yml:17, so it was left as it is (the branch's newest commit older than the cooldown is not among the 500 newest commits examined)
```

> [!IMPORTANT]
> Once the comment names a version tag, the reference is a version pin like any other. Later runs move it to newer versions, and no longer follow the branch. That happens in the run that first pins the branch, when a version tag points at its commit. It also happens when an adopted move lands on a tagged commit. To keep following the branch, mark the reference `# update-time: allow[floating-pin]` (see [Keeping a pin floating](#keeping-a-pin-floating)).

A reference kept floating with `allow[floating-pin]` keeps naming the branch. The reference does so when a version tag comes to point at its pinned commit. It also does so when it adopts the branch's new commit (see [Keeping a pin floating](#keeping-a-pin-floating)).

#### Cooldown

The cooldown is measured against the release's publication date. A version that was only tagged takes the publication date of the first release of the same commit, so a `v4.3` tag waits as long as a `v4.3.0` release of that commit. Without such a release, the cooldown is measured against the date of the commit the tag points at. Update-time skips a version whose commit date it cannot fetch, rather than adopting it with the cooldown unchecked. It then skips the older versions as well, and stays on the version the reference names, since the failure, such as a rate limit, would hold for them too. It logs why at `ERROR`:

```console
ERROR Could not determine from GitHub when actions/checkout v4.4.0 was committed (HTTP 403, API rate limit exceeded), so the cooldown can't be verified; actions/checkout stays on 4.3.0 rather than trying the versions in between
```

Update-time measures the cooldown of a moved tag's or branch's new commit against the committer date. GitHub does not report when a tag or branch was moved, and whoever makes a commit sets its committer date. So a tag moved onto an old commit passes the cooldown at once, and so does an old commit pushed onto a branch. Update-time adopts a moved tag's new commit only when it can fetch the commit's date. When it cannot, it holds the drift back and logs why at `ERROR`:

```console
ERROR Could not determine from GitHub the date of commit … that actions/checkout@4.1.1 in .github/workflows/ci.yml:17 now points at (HTTP 403, API rate limit exceeded), so the cooldown can't be verified; not adopting the drift
```

#### Stale dependencies

Staleness is measured against the repository's newest release, whatever the reference names: a version, a branch, or a commit.

#### Yanked dependencies

GitHub does not record a yank, so Update-time checks neither an action nor a hook `rev:` for one.

#### Vulnerable dependencies

Update-time checks neither. OSV does hold advisories for actions, but their affected entries enumerate no versions. So a question to OSV about a version of an action returns nothing, however that version is spelled. Silence there says nothing about whether the action is safe. A hook repository is not an OSV package at all.

#### Archived dependencies

Update-time checks both against the `archived` flag GitHub publishes for a repository. Archival is a fact about the repository rather than about one of its versions. So Update-time reads the flag whatever the reference names: a version, a branch, or a commit. GitHub publishes no reason beside the flag, so the warning gives none. Update-time reports an action or a hook whose repository is archived, unless an `ignore[archived]` marker silences that warning.

#### Markers

Both take an inline marker on the reference's own line. In a `.pre-commit-config.yaml`, the marker follows the `# frozen:` comment on the `rev:` line when both are present:

```yaml
rev: <sha>  # frozen: v4.5.0  # update-time: ignore
```

### Node engine version and Python version

Both name the runtime a project runs on, rather than a package it depends on. Both follow the project's Dockerfile where there is one, so the runtime you develop against and the one you ship stay in step.

#### What files are updated?

For the Node engine version, Update-time looks for `package.json` files that specify a [Node engine](https://docs.npmjs.com/cli/v12/configuring-npm/package-json#engines).

For the Python version, it looks for `.python-version` files recursively from the starting path. That covers a repository that pins its Python version at the root, and a monorepo that pins one per package. `.python-version` is the de facto standard for pinning a project's Python version, read by uv, pyenv, and GitHub's `setup-python` action, among others.

#### What dependencies are updated?

The Node engine version in the `package.json`: the `node` entry its `engines` section declares.

In a `.python-version` file, each plain CPython version on a line of its own, `X.Y` or `X.Y.Z`. Examples are `3.12` and `3.12.6`. A file may list several entries, one per line (pyenv reads more than one), each handled independently. Update-time leaves the other entries untouched: alternative implementations (`pypy3.10-7.3.12`, `miniconda3-…`), free-threaded and other variant suffixes (`3.13t`), prefixed forms (`cpython@3.12`, `>=3.10`), and the `system` sentinel.

Update-time leaves other Python version pins untouched too. The `requires-python` value in `pyproject.toml` and in PEP 723 inline script metadata is not a `.python-version` entry, and stays as it is.

#### What versions are updated?

Update-time takes the new version from the matching base image in the project's Dockerfile. That needs a Dockerfile in the same folder, whose base image has a numeric version. When no Dockerfile declares one, Update-time takes the version from the latest [Node](https://hub.docker.com/_/node) or [Python](https://hub.docker.com/_/python) release on Docker Hub.

Update-time updates the Node engine version only when it contains a specific version, for example `26.4`. It leaves a range or other non-numeric value untouched. A Node base image whose tag carries no version to sync to, such as `node:lts` or `node:22.x`, is the exception to following the Dockerfile. Update-time then leaves the engine alone, rather than overriding it with a mismatched concrete version.

Update-time moves a `.python-version` entry forward to a fuller version. The entry adopts the image's version at the precision the tag provides, so `python:3.14.2-slim` yields `3.14.2` and a bare `python:3.14` yields `3.14`. Update-time leaves an entry already ahead of the image alone, rather than downgrading it.

The Node engine goes the other way, since it declares the runtime the project ships. Update-time brings an engine ahead of its base image back to the image's version. Docker Hub always names a full version, so an entry that follows Docker Hub gains precision it didn't have. Both `3.12.6` and `3.12` become `3.13.2`, or whatever the latest is.

#### Pinning

Neither can carry a hash pin. Both name a version rather than one artefact, and a version covers every build ever published for it. So there is no image digest, commit SHA, or integrity hash to add. Neither format has anywhere to put one either: a `package.json` entry names a version, and a `.python-version` line is a bare version. Update-time only moves them to a fuller version, which makes them more precise but verifies nothing.

#### Cooldown

A version taken from Docker Hub honours the cooldown through the Node or Python image tag's push date. A version that instead follows the project's Dockerfile needs no cooldown of its own, since Update-time already applied one when it updated the base image.

#### Stale dependencies

Both are indirect cases. When the version comes from the project's Dockerfile, Update-time reports the staleness of the base image alone. It checks neither the entry nor the engine itself then. It checks a version taken from Docker Hub against the newest Node or Python release there.

#### Yanked dependencies

Neither source has a yank concept, so Update-time checks neither for one.

#### Vulnerable dependencies

Update-time checks neither. Both name a version of a runtime rather than a release of a package, which is not something OSV matches an advisory against.

#### Archived dependencies

Update-time checks neither. Both take their version from a Docker base image or from Docker Hub, and no OCI registry publishes an archival signal.

#### Markers

A `.python-version` entry takes a marker in either placement. But uv rejects an inline comment on a `.python-version` line: it ignores the entry and silently resolves a different Python. So the line-above form is the safer placement for a uv project:

```text
# update-time: ignore
3.12
```

The Node engine version takes a marker too, but not as a comment: `package.json` is strict JSON, which has nowhere to put one. Its marker goes in an `update-time` field instead, which mirrors the file's own structure. The marker steering the engine therefore sits under `engines` and `node`, as the engine itself does:

```json
{
  "engines": { "node": "22" },
  "update-time": { "engines": { "node": "allow[update<23]" } }
}
```

The field holds the directives a comment holds, without the `# update-time:` prefix that introduces them there. npm and pnpm keep a field they do not know, so the marker survives an `npm update` and a `pnpm update`. Both rewrite the file onto one key per line, so the field appears spread over several lines rather than on the one shown here. Update-time reports a field of the wrong shape as an invalid item, and leaves the engine as it is (see [Invalid markers](#invalid-markers)).

Two kinds of engine are never updated: one declared as a range, and one whose base image tag names no version to sync to. `node:lts` is such a tag. A marker steers an update, so on either engine it has nothing to steer, and Update-time has nothing to report.

Either way a marker wins over a version derived from the Dockerfile. So an image update never drags a deliberately held-back development version forward.

### Docker images

#### What files are updated?

Update-time looks for these files, each with its own globs and in its own folder, searching recursively where the table says so:

| Files | Globs | Folder | Recursive |
| :---- | :---- | :----- | :-------: |
| Dockerfiles | `Dockerfile`, `*.Dockerfile`, `Dockerfile.*` | the scan root | ✅ |
| CircleCI configs | `*.yml`, `*.yaml` | `.circleci/` | ✅ |
| .gitlab-ci.yml | `.gitlab-ci.yml` | the scan root |  |
| Docker Compose files | `docker-compose*.yml` | the scan root | ✅ |
| Helm charts | `*.yml`, `*.yaml` | `helm/` | ✅ |
| devcontainer configs | `.devcontainer.json`, `.devcontainer/devcontainer.json`, `.devcontainer/*/devcontainer.json` | the scan root | ✅ |

#### What dependencies are updated?

| Files | Dependencies |
| :---- | :-----------  |
| Dockerfiles | Base images (`FROM` references) |
| CircleCI configs | Docker images (machine-executor images are left unchanged) |
| .gitlab-ci.yml | Docker images (`image:` references) |
| Docker Compose files | Service images (`image:` references; an image the file builds is left unchanged) |
| Helm charts | Container images (`image:` references) |
| devcontainer configs | The base image and each feature |

Update-time reads a Dockerfile's `FROM` the way Docker reads it: in upper or lower case, and only where it opens its line. Neither `FROM scratch` nor a `FROM` naming one of the file's own build stages names an image a registry serves, so Update-time leaves both alone.

A Docker Compose service that declares a `build:` beside its `image:` names the image the file builds. That image is the file's output rather than a dependency to update, so Update-time leaves it alone. It leaves the other services naming that image alone as well, including the ones that build nothing themselves. The reference is matched by name and tag, so another tag of the same repository is a registry image like any other and is updated as one. Update-time reads one file at a time, so an image one Docker Compose file builds is updated in another file that pulls it.

Update-time reports at `DEBUG` why the file does not pull an image:

```console
DEBUG Reference acme/api:1.2.3 in docker-compose.yml:4 was left as it is: the Compose file builds this image
DEBUG Reference ubuntu-2204:2024.01.1 in .circleci/config.yml:4 was left as it is: it names a CircleCI machine executor rather than an image a registry serves
DEBUG Reference scratch in Dockerfile:1 was left as it is: it names the empty base image Docker builds from, which no registry serves
DEBUG Reference deps in Dockerfile:2 was left as it is: it names one of the Dockerfile's own build stages
```

#### What versions are updated?

When updating an image tag, Update-time keeps the non-numeric parts of the tag and only advances its version numbers. A tag such as `python:3.14.6-alpine3.23` has three parts: the label prefix `python`, the main version `3.14.6`, and the suffix `alpine3.23`. Update-time preserves the label prefix and the suffix's label, so it never replaces a variant: `python` never becomes `pypy`, and `alpine` never becomes `debian`. It upgrades both the main version and a version embedded in the suffix, independently or together, for example `3.14.6-alpine3.23` → `3.15.0-alpine3.24`. A suffix without an embedded version (`bookworm-slim`, `windows`) is never updated, since it carries no version to advance. It never downgrades one axis to adopt a newer value on the other.

A floating tag such as `latest` or `trixie`, or a reference without a tag, is pinned rather than updated (see [Floating image tags](#floating-image-tags)).

A dated snapshot such as `debian:bookworm-20260803` names the day its image was built. Update-time updates it to the newest snapshot under its label, so a reference on `bookworm-20240110` moves to `bookworm-20260803`. A repository can also name a snapshot by the date alone, as Alpine names one `20260805` beside its `3.24.1` release. Update-time updates such a tag to the newest snapshot as well. A reference on a release keeps following releases, and a reference on a snapshot keeps following snapshots.

#### Pinning

Update-time appends the `@sha256:digest` of the (latest) tag to an image referenced by tag only, so the image is reproducible. This covers base images in Dockerfiles (`FROM image:tag`), CircleCI images, GitLab CI images, Docker Compose and Helm manifest images, and devcontainer base images and features. Update-time takes the image's registry from the reference, so it resolves images on Docker Hub and on other OCI registries (`ghcr.io`, `mcr.microsoft.com`, …) alike.

Update-time ignores an image whose tag it cannot read, so a reference through a `{{ ... }}` template or `${VAR}` variable substitution gets no digest. Update-time does not ask a registry about an image the file does not pull, so that image gets none either. Such images are a CircleCI machine-executor image (the `image:` under a `machine:` key, such as `ubuntu-2204:2024.01.1`), an image a Docker Compose file builds, `scratch`, and one of the Dockerfile's own build stages.

#### Cooldown

Update-time adopts a newer tag, or a re-pushed digest, only once it is older than the cooldown, provided Docker Hub hosts the image. Other registries (`ghcr.io`, `mcr.microsoft.com`, …) do not expose a publication date, so Update-time updates images there, and adopts a re-pushed digest, without a cooldown.

When Docker Hub does not report the push date of a newer tag or a re-pushed digest, Update-time cannot verify the cooldown. It holds the drift back, or stays on the tag the reference names rather than trying the other newer tags, and logs why at `ERROR`:

```console
ERROR Could not determine from Docker Hub when python:3.15.0 was pushed (HTTP 429), so the cooldown can't be verified; python stays on 3.14.6 rather than trying the tags in between
ERROR Could not determine from Docker Hub when python:3.14 in Dockerfile:2 was pushed (HTTP 429), so the cooldown can't be verified; not adopting the drift
```

#### Stale dependencies

Staleness is measured against the image's newest release, on Docker Hub only, since other registries do not expose a publication date.

Update-time reports no staleness for a reference whose tag the registry does not serve. That tag names no image in the repository, so the image's newest release says nothing about the reference. An image a pipeline builds without pushing it is the common case, and a deleted or mistyped tag reads the same way.

Two kinds of tags are dated all the same. A tag naming a version is, whether or not the registry serves it. So is a tag listed further down the repository's tag list than Update-time reads, since what the registry serves under such a tag is unknown.

#### Yanked dependencies

An OCI registry does not record a yank, so Update-time does not check an image for one.

#### Vulnerable dependencies

Update-time does not check an image, because OSV has no ecosystem for container images. Reporting the vulnerabilities of the packages inside an image is what an image scanner is for.

#### Archived dependencies

Update-time does not check an image, because no OCI registry publishes an archival signal.

#### Markers

In a Dockerfile the marker goes on the line above the `FROM`, since Dockerfiles don't allow inline comments. In the YAML formats — CircleCI, GitLab CI, Docker Compose, and Helm — it can go inline on the image's own line. In a `devcontainer.json` it goes in a `//` comment.

### jsDelivr npm URLs

#### What files are updated?

Update-time looks for Sphinx configuration files (`conf.py`) under the `docs/` folder, recursively.

#### What dependencies are updated?

The jsDelivr npm URLs and their accompanying Subresource Integrity (`integrity`) hash. For example: `https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js`.

#### What versions are updated?

Update-time updates the npm package version embedded in the URL to the latest version on the npm registry. It updates the SRI hash in step, so the two stay consistent.

#### Pinning

A URL whose attribute dictionary declares no `integrity` entry gains one, so the browser verifies the script the CDN serves before running it. Update-time inserts the hash in front of the entries the dictionary already has, and reports it as a pin:

```console
INFO Pinned clipboard in docs/conf.py:4 to 2.0.11@sha256-…
```

A URL declared as a bare string, without an attribute dictionary, has nowhere to hold an integrity hash, so it stays without one. Adding a hash would mean rewriting the string into a `(url, {"integrity": …})` tuple, which is more than rewriting a line. So Update-time logs it at `INFO` and leaves it alone. Declare the URL as such a tuple to have it pinned.

#### Cooldown

Update-time adopts a newer version only once it is older than the cooldown, measured against its publication date on the npm registry.

#### Stale dependencies

Update-time checks the URL's package against its newest release on the npm registry.

#### Yanked dependencies

Update-time checks the version in the URL against the npm registry's deprecations. It warns about a URL the run leaves on a deprecated version.

#### Vulnerable dependencies

Update-time checks the version in the URL against OSV's npm advisories. It warns about a URL the run leaves on a version an advisory names, unless an `ignore[vulnerable]` marker silences that warning.

#### Archived dependencies

Update-time does not check a jsDelivr URL, since the npm registry does not publish an archival signal.

#### Markers

A jsDelivr URL takes an inline marker in a `#` comment on its own line in `conf.py`.

## 📮 Point of contact

Point of contact for this repository is [Frank Niessink](https://github.com/fniessink).
