You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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":
Pool = all available versions newer than the current pin (what the source already computes).
allow[update SPEC] keeps candidates where SPEC.contains(v); ignore[update SPEC] keeps candidates where not SPEC.contains(v).
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:
Ceiling — allow[update<3.13] ≡ ignore[update>=3.13]. Same result.
Containment vs. hole — allow[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 subtle — allow[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):
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.
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_action → get_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 firstignore 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.
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.
Problem
The
# update-time: ignoremarker 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 gettingpython:3.12patch releases but don't move to3.13until you've migrated. There's no way to say that for a line-based reference today.(Python
pyproject.toml/requirements.txtand npmpackage.jsoncan 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 theupdatescope 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:Two complementary framings, both supported (pick whichever reads best):
allow[update<SPEC>]— keep only update candidates that satisfySPEC.ignore[update<SPEC>]— drop update candidates that satisfySPEC(the existingignore[update]is the drop-everything case).So a ceiling "stay below 3.13" is either
allow[update<3.13]orignore[update>=3.13].Semantics — the candidate-set model. Both framings reduce to a filter over the existing candidate pool, applied before "pick the highest":
allow[update SPEC]keeps candidates whereSPEC.contains(v);ignore[update SPEC]keeps candidates wherenot SPEC.contains(v).Filtering the pool before selection is essential, not a post-hoc veto: with
current=3.12.1,allow[update<3.13], and3.13available, a "compute latest, skip if it violates" implementation would reject3.13and miss3.12.9. Filter first, then pick highest. (Doubly important for theignoreframing, whose "ignore the update" wording invites the wrong post-veto reading.)allowandignoreare complements — mind the range flipThey are duals over the candidate set, so for a simple bound they're interchangeable, but for a range they mean opposite things:
allow[update<3.13]≡ignore[update>=3.13]. Same result.allow[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 picks3.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.allow[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:
3.12patches but block3.13:allow[update<3.13],allow[update==3.12.*], orallow[update~=3.12.0](equivalentlyignore[update>=3.13]).allow[update<=3.12]is a hard ceiling at exactly3.12— it also blocks3.12.1, since3.12.1 > 3.12in PEP 440. Rarely what "stay on 3.12" means. (Same trap in the ignore framing:ignore[update>3.12]also blocks3.12.1.)Compound sets (
>=3.10,<3.13) come for free withSpecifierSet; 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.ignoreskips the line before any lookup; a version filter must influence which version the source selects, so it has to reach the version lookup:NewVersionGettercontract ((dependency, version) -> DependencyVersion) grows an optional trailing filter argument,(dependency, version, version_filter=None), defaulting toNoneso existing call sites keep working. All three version sources now select the same way — narrow a candidate pool, then pick the highest surviving version viafirst_eligible— so each applies the filter as one more predicate on that pool: candidate filtering insources/oci.py(is_candidate_for) for images, and the same inpypi.get_latest_versionandgithub.get_latest_versionfor PyPI packages and GitHub actions. (github.get_latest_versionnow matches theNewVersionGettercontract directly, since the GitHub source was aligned withfirst_eligible.)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 singleSpecifierSet(the complement of==3.12.*isn't one clause), so carry the specifier plus an allow/drop flag and let the source computekeep = specifier.contains(v) == allow; (b)SpecifierSetis 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):
_Rewriter(io/rewrite.py), used by Dockerfiles, manifests (Compose/Helm), CircleCI, GitLab CI, devcontainer, andrequirements.txt. It already receives theMarkerinupdate_line, so it can passmarker.version_filterstraight through toget_new_version(...). Straightforward._update_action(updaters/update_github_action.py), which does not go through_Rewriter. It callsgithub.get_latest_version, which — since the GitHub source was aligned withfirst_eligible— now selects over a newest-first candidate list, the same shape asoci.get_latest_tagandpypi.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, somarker.version_filtermust be forwarded through_update_action→get_latest_versionexplicitly rather than riding the shared_Rewriter.update_line.Parsing and validation
The grammar extends
_marker()/theignore[...]/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 wayignoreandallow[digest-drift]already stack on one reference. Each marker regex carries its ownupdate-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], notignore[update>=3.13, stale]. One caveat:_markerkeeps only the firstignorescope today, so twoignore[...]directives on one reference (anignore[update…]plusignore[stale]) additionally need it to accumulate everyignorescope rather than the first; anallow[update…]predicate paired withallow[digest-drift]already composes, since those land in separateMarkerfields._marker()is pure (noLogger/path), so it carries the raw specifier (or a parsed filter plus an "invalid" sentinel), and validation/logging happens one level up inupdated_lines/_Rewriter, where the logger and path are available; an invalid specifier is logged there and the reference left unchanged. Require the explicitupdatescope — no bareallow[<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
ignore(orignore[update]with no specifier — hold back all updates) on the same reference wins over anyallow[update…]/ignore[update…]predicate; the reference is left untouched. Consistent withignorealready beatingallow[digest-drift].allow[update…]and anignore[update…]on one line) is undefined; use a single predicate.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.A dead marker is warned about
Reduce the marker to its effective allowed set (for
ignore[update SPEC], the complement ofSPEC; forallow[update SPEC],SPECitself). 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 aWARNING. Two shapes (examples given as the equivalent allowed set):allow[update<3.13]on a pin already at3.14, or a pathologicalallow[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.allow[update>3.14]/ignore[update<=3.14]on3.15, orallow[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
ignore,ignore[update],ignore[stale], andallow[digest-drift]keep their current meanings;ignore[update]is exactly the no-specifier (drop-all) case of the extended grammar. Only the optional specifier afterupdate, and theallow[update SPEC]scope, are new.allwo[…],ignore[updte]) still silently no-ops, as today. A malformed specifier inside a recognisedallow[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.allow[update<3.13]is not reported stale even if the3.12line is dead while3.13is active, the same way a<=cap inrequirements.txtbehaves today. Deliberate and consistent.Acceptance criteria
python:3.12.1with# update-time: allow[update==3.12.*](orallow[update<3.13], orignore[update>=3.13]) bumps to the latest available3.12patch, never to3.13.allow[update…]andignore[update…]framings work, in inline and comment-above forms, across the same line-based updaters asignore: Dockerfiles, Docker Compose / Helm, CircleCI, GitLab CI, GitHub Actions, devcontainer (//marker), andrequirements.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.3.12.9is still reached when3.13is excluded).WARNING.ignore,ignore[update],ignore[stale],allow[digest-drift]) behave exactly as before; lines without a marker behave exactly as before.Notes
pyproject.tomlandpackage.json(updated through uv / npm / pnpm rather than line by line) — use their native version specifiers there, which uv andnpm updatehonor. The exception is pnpm: Update-time runspnpm update --latest, which ignores the declared range and bumps to the newest release, so a native ceiling inpackage.jsondoes 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 declaredpackage.jsonranges, 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 theignoremarker.python-prefixed,-bookworm-slim, and any suffix-embedded version such asalpine3.23) is unchanged. Soallow[update<3.13]onpython:3.12-alpine3.23bounds only the3.12axis.stalepredicate (ignore[stale<1000]as a per-reference staleness threshold). The grammar is designed to accommodate it — a specifier after thestalescope, ranging over days rather than versions — but it is a separate feature and not implemented here.