Skip to content

Add allow[update<…>] / ignore[update<…>] markers to bound how far a reference is updated #58

Description

@fniessink

Problem

The # update-time: ignore marker holds a reference back completely, and # update-time: ignore[update] holds back its updates while still checking staleness. Both are all-or-nothing for the version: you either take the latest or freeze. Sometimes you want the middle ground — keep receiving updates within a range while blocking a jump you're not ready for, e.g. keep getting python:3.12 patch releases but don't move to 3.13 until you've migrated. There's no way to say that for a line-based reference today.

(Python pyproject.toml/requirements.txt and npm package.json can already express this with a native specifier the package manager resolves — package<3.13, "<3.13.0"; image and action references cannot.)

Proposed solution — extend the bracket scope with a specifier

The marker grammar is already # update-time: <verb>[<scope>]ignore[update], ignore[stale], allow[digest-drift]. Extend the update scope to carry an optional PEP 440 specifier that narrows which updates the directive applies to. ignore[update] (no specifier) stays the existing "hold back all updates"; a specifier narrows it:

# update-time: allow[update<3.13]
FROM python:3.12.1-bookworm-slim
image: redis:7.2  # update-time: allow[update==7.*]
uses: actions/checkout@v4  # update-time: ignore[update>=5]

Two complementary framings, both supported (pick whichever reads best):

  • allow[update<SPEC>]keep only update candidates that satisfy SPEC.
  • ignore[update<SPEC>]drop update candidates that satisfy SPEC (the existing ignore[update] is the drop-everything case).

So a ceiling "stay below 3.13" is either allow[update<3.13] or ignore[update>=3.13].

Semantics — the candidate-set model. Both framings reduce to a filter over the existing candidate pool, applied before "pick the highest":

  1. Pool = all available versions newer than the current pin (what the source already computes).
  2. allow[update SPEC] keeps candidates where SPEC.contains(v); ignore[update SPEC] keeps candidates where not SPEC.contains(v).
  3. Pick the highest surviving candidate (cooldown, digest, … unchanged).

Filtering the pool before selection is essential, not a post-hoc veto: with current=3.12.1, allow[update<3.13], and 3.13 available, a "compute latest, skip if it violates" implementation would reject 3.13 and miss 3.12.9. Filter first, then pick highest. (Doubly important for the ignore framing, whose "ignore the update" wording invites the wrong post-veto reading.)

allow and ignore are complements — mind the range flip

They are duals over the candidate set, so for a simple bound they're interchangeable, but for a range they mean opposite things:

  • Ceilingallow[update<3.13]ignore[update>=3.13]. Same result.
  • Containment vs. holeallow[update>=3.13,<3.15] keeps candidates within [3.13,3.15); ignore[update>=3.13,<3.15] drops that band (a hole — from {3.12,3.13,3.14,3.15,3.16} it picks 3.16, skipping 3.13–3.14). Both are valid and useful, but they are opposites; docs must make this explicit so users don't get the inverse of what they meant.
  • Floor is subtleallow[update>=3.13] / ignore[update<3.13] refuse sub-3.13 bumps, but because we always pick the highest candidate this is usually a no-op (the highest is already ≥3.13 if anything ≥3.13 exists). It only bites when every available newer version is <3.13, where it means "don't bump at all until 3.13+ is reachable" — not "pull up to 3.13."

Operator semantics (decide deliberately)

The value is a literal specifier, so pick the operator that matches the intent — a common trap:

  • To keep 3.12 patches but block 3.13: allow[update<3.13], allow[update==3.12.*], or allow[update~=3.12.0] (equivalently ignore[update>=3.13]).
  • allow[update<=3.12] is a hard ceiling at exactly 3.12 — it also blocks 3.12.1, since 3.12.1 > 3.12 in PEP 440. Rarely what "stay on 3.12" means. (Same trap in the ignore framing: ignore[update>3.12] also blocks 3.12.1.)

Compound sets (>=3.10,<3.13) come for free with SpecifierSet; allow and document them. Docs and README examples should use <3.13 / ==3.12.*, never <=3.12.

Key design consideration — the filter must reach the source

Unlike a bare ignore, this can't live purely at the line level. ignore skips the line before any lookup; a version filter must influence which version the source selects, so it has to reach the version lookup:

  • The NewVersionGetter contract ((dependency, version) -> DependencyVersion) grows an optional trailing filter argument, (dependency, version, version_filter=None), defaulting to None so existing call sites keep working. All three version sources now select the same way — narrow a candidate pool, then pick the highest surviving version via first_eligible — so each applies the filter as one more predicate on that pool: candidate filtering in sources/oci.py (is_candidate_for) for images, and the same in pypi.get_latest_version and github.get_latest_version for PyPI packages and GitHub actions. (github.get_latest_version now matches the NewVersionGetter contract directly, since the GitHub source was aligned with first_eligible.)
  • Pass the filter as a small hashable value — e.g. a frozen VersionFilter(specifier: SpecifierSet, allow: bool) — not a raw specifier and not a closure. Two reasons: (a) ignore's allowed set is the complement of its specifier, which is not always a single SpecifierSet (the complement of ==3.12.* isn't one clause), so carry the specifier plus an allow/drop flag and let the source compute keep = specifier.contains(v) == allow; (b) SpecifierSet is hashable and a frozen dataclass stays hashable, so the filter threads through the @cached lookups (get_latest_tag's helpers, pypi.get_latest_version, github.get_latest_version) without breaking or thrashing their caches (a closure would defeat caching by identity).

Two rewrite paths, not one

The filter has to reach both rewrite paths in the code (this is about the rewrite layer; the source selection is now uniform across OCI/PyPI/GitHub — see above):

  1. The generic _Rewriter (io/rewrite.py), used by Dockerfiles, manifests (Compose/Helm), CircleCI, GitLab CI, devcontainer, and requirements.txt. It already receives the Marker in update_line, so it can pass marker.version_filter straight through to get_new_version(...). Straightforward.
  2. The GitHub Actions updater's own _update_action (updaters/update_github_action.py), which does not go through _Rewriter. It calls github.get_latest_version, which — since the GitHub source was aligned with first_eligible — now selects over a newest-first candidate list, the same shape as oci.get_latest_tag and pypi.get_latest_version. So honouring a filter here is the same candidate-pre-filter as the other sources (drop rejected candidates before picking the highest), not a special case. What remains distinct is only that this path bypasses _Rewriter, so marker.version_filter must be forwarded through _update_actionget_latest_version explicitly rather than riding the shared _Rewriter.update_line.

Parsing and validation

The grammar extends _marker()/the ignore[...]/allow[...] bracket parsing: a scope keyword (update) optionally followed by a specifier. The split is unambiguous — the scope is a known identifier, the specifier starts with an operator (<, >, =, ~, !) or a digit. Note the comma is reserved for compound specifiers (<3.15,>=3.13), so it can't also separate scopes in one bracket — and there is no other in-bracket delimiter for a second scope either. Combine directives by repeating the # update-time: prefix: one # update-time: … comment per directive, inline and/or on the line directly above, the same way ignore and allow[digest-drift] already stack on one reference. Each marker regex carries its own update-time: lead and is searched independently, so a second directive needs a fresh prefix — space-sharing one lead (# update-time: ignore[update>=3.13] stale) won't match. So write # update-time: ignore[update>=3.13] # update-time: ignore[stale], not ignore[update>=3.13, stale]. One caveat: _marker keeps only the first ignore scope today, so two ignore[...] directives on one reference (an ignore[update…] plus ignore[stale]) additionally need it to accumulate every ignore scope rather than the first; an allow[update…] predicate paired with allow[digest-drift] already composes, since those land in separate Marker fields. _marker() is pure (no Logger/path), so it carries the raw specifier (or a parsed filter plus an "invalid" sentinel), and validation/logging happens one level up in updated_lines/_Rewriter, where the logger and path are available; an invalid specifier is logged there and the reference left unchanged. Require the explicit update scope — no bare allow[<3.13] sugar, which reintroduces the ambiguity the keyword form removes. allow[update] with no specifier is the default (allow everything) and a no-op.

Marker precedence and interaction

  • A freeze beats a version filter. A bare ignore (or ignore[update] with no specifier — hold back all updates) on the same reference wins over any allow[update…]/ignore[update…] predicate; the reference is left untouched. Consistent with ignore already beating allow[digest-drift].
  • One version filter per reference. Combining conflicting predicates (an allow[update…] and an ignore[update…] on one line) is undefined; use a single predicate.
  • Composes with allow[digest-drift]. When a filter holds the version steady but the chosen tag's digest has drifted, the existing digest-drift handling still applies to the selected version.
  • Digest pinning is unchanged. For whichever version the filter selects (even when it equals the current version), the digest is pinned/refreshed exactly as without a filter.

A dead marker is warned about

Reduce the marker to its effective allowed set (for ignore[update SPEC], the complement of SPEC; for allow[update SPEC], SPEC itself). It is dead when it can never cap an update from the current version — almost always a mistake, a leftover from a completed migration, or a </> typo. Log a WARNING. Two shapes (examples given as the equivalent allowed set):

  • Over-constrained — the allowed set admits nothing at or ahead of the current version (e.g. allow[update<3.13] on a pin already at 3.14, or a pathological allow[update<0]), so no candidate survives and the reference stays put on its own. Detected via the candidate check: the current version isn't in the allowed set and no newer version is either. The test runs on the version-satisfying candidate set before cooldown/digest eligibility, so a bound whose only matches are still within the cooldown is not reported dead — it is alive and simply waiting.
  • Vacuous — the allowed set has no effective upper bound above the current version (e.g. allow[update>3.14] / ignore[update<=3.14] on 3.15, or allow[update>=3.12] the current version already satisfies), so it caps nothing; the reference updates to the latest surviving version exactly as if unmarked. Detected structurally: the current version is in the allowed set and an arbitrarily large sentinel version still is.

Behaviour and compatibility

  • Fully backward compatible. ignore, ignore[update], ignore[stale], and allow[digest-drift] keep their current meanings; ignore[update] is exactly the no-specifier (drop-all) case of the extended grammar. Only the optional specifier after update, and the allow[update SPEC] scope, are new.
  • Typo behaviour is unchanged for verbs/scopes; only bad specifiers surface. A mistyped verb or scope (allwo[…], ignore[updte]) still silently no-ops, as today. A malformed specifier inside a recognised allow[update…]/ignore[update…] is unambiguously an error and is logged — the keyword grammar removes the earlier ambiguity of a bare specifier, where a typo and a broken specifier were indistinguishable.
  • Staleness is unaffected by a version filter. The staleness check still compares against the project's newest overall release, not the newest within the bound — so allow[update<3.13] is not reported stale even if the 3.12 line is dead while 3.13 is active, the same way a <= cap in requirements.txt behaves today. Deliberate and consistent.

Acceptance criteria

  • python:3.12.1 with # update-time: allow[update==3.12.*] (or allow[update<3.13], or ignore[update>=3.13]) bumps to the latest available 3.12 patch, never to 3.13.
  • Both allow[update…] and ignore[update…] framings work, in inline and comment-above forms, across the same line-based updaters as ignore: Dockerfiles, Docker Compose / Helm, CircleCI, GitLab CI, GitHub Actions, devcontainer (// marker), and requirements.txt.
  • allow[update>=3.13,<3.15] keeps the reference within [3.13,3.15); ignore[update>=3.13,<3.15] excludes that band (a hole), confirming the framings are complements.
  • The filter is applied during candidate selection, not as a post-hoc veto (a 3.12.9 is still reached when 3.13 is excluded).
  • The digest is pinned/refreshed for whichever version the filter selects, including when that equals the current version.
  • A filter the current version satisfies with no newer surviving candidate is a silent, unlogged no-op; a later run bumps once a newer surviving version is published.
  • An invalid (unparseable) specifier is logged and the reference left unchanged, rather than crashing.
  • A dead filter — over-constrained or vacuous, per A dead marker is warned about — is logged as a WARNING.
  • Existing markers (ignore, ignore[update], ignore[stale], allow[digest-drift]) behave exactly as before; lines without a marker behave exactly as before.

Notes

  • Out of scope: pyproject.toml and package.json (updated through uv / npm / pnpm rather than line by line) — use their native version specifiers there, which uv and npm update honor. The exception is pnpm: Update-time runs pnpm update --latest, which ignores the declared range and bumps to the newest release, so a native ceiling in package.json does not hold under pnpm and this marker can't help there either (that pnpm gap is tracked separately as a bug in pnpm updates ignore declared package.json ranges, unlike npm and Python #135). Likewise the Node engine version (derived from the project's Dockerfile, not a registry release) and jsDelivr URLs (rewritten whole-file, not line by line), matching the reach of the ignore marker.
  • For image tags the specifier applies to the parsed main version component; prefix/suffix matching (e.g. python-prefixed, -bookworm-slim, and any suffix-embedded version such as alpine3.23) is unchanged. So allow[update<3.13] on python:3.12-alpine3.23 bounds only the 3.12 axis.
  • Deferred to its own issue: a stale predicate (ignore[stale<1000] as a per-reference staleness threshold). The grammar is designed to accommodate it — a specifier after the stale scope, ranging over days rather than versions — but it is a separate feature and not implemented here.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Fields

No fields configured for Feature.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions