Skip to content

Support container image updates (Dockerfile, Compose, workflow containers) - #31

Merged
owjs3901 merged 95 commits into
mainfrom
fix-local-version-issue
Aug 21, 2026
Merged

Support container image updates (Dockerfile, Compose, workflow containers)#31
owjs3901 merged 95 commits into
mainfrom
fix-local-version-issue

Conversation

@owjs3901

@owjs3901 owjs3901 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Container image updates

Adds a fifth ecosystem: dcu now tracks the images a project builds on.

Scanned Resolved against
Dockerfile FROM instructions OCI Distribution tags/list
Compose service image: keys "
Workflow job container: / services: image: keys "
$ dcu -d
Checking .github/workflows/CI.yml
 actions/checkout           v4  ->  v7
 node                18-alpine  ->  26-alpine
 postgres                   15  ->  18

Checking Dockerfile
 node      18-alpine  ->  26-alpine
 python    3.10-slim  ->  3.14-slim

Checking compose.yaml
 postgres                             15-alpine  ->  18-alpine
 ghcr.io/dependabot/dependabot-core     0.169.7  ->  0.215.0

Build variants are never crossed

A container tag is a version plus a variant, so bumping node:20-alpine to node:22 would silently swap Alpine for Debian. Candidate tags are grouped by the verbatim suffix after the leading numeric run, and only tags within one group are ever compared:

node:20-alpine    ->  22-alpine      (not 22)
python:3.12-slim  ->  3.13-slim
postgres:16.0     ->  16.15          (-t minor)

Treating the suffix as opaque means 1.2.3-rc1 lands in its own -rc1 lane rather than being read as a semver pre-release. That is deliberate: -rc1 and -alpine are indistinguishable at the tag level, and guessing wrong emits a tag that does not exist. Staying inside the lane can only under-report an update, never break an image.

Pin precision is preserved when a published tag backs it (node:20 -> node:22, not node:22.3.0), escalating to the shortest form that actually exists otherwise ??so the emitted tag always pulls.

Left untouched on purpose

FROM node / image: redis (untagged) 쨌 :latest:bookwormnode:20@sha256:?? 쨌 node:${NODE_VERSION}FROM builder(stage reference) 쨌app:1a2b3c4` (build hash).

Registries

One code path serves Docker Hub, ghcr.io, quay.io, mcr.microsoft.com, public.ecr.aws, and self-hosted registries. Public images authenticate through the anonymous Bearer-token challenge automatically; private repositories surface an explicit error rather than a guess. localhost / 127.0.0.1 use plain HTTP, matching the daemon's own insecure-registry default. -t newest falls back to greatest ??the OCI tag list carries no publish dates.

Shared-code changes

  • is_version_ref and the YAML scalar-bounds scanner move into core; the GitHub Actions crate now shares them instead of carrying its own copies (net -128 lines in crates/github/src/parser.rs).
  • compute_updates picks its rewrite policy per dependency section rather than per manifest kind. A single workflow can carry both uses: refs and image: pins, so the ManifestKind parameter is gone and resolves_to_an_exact_ref(section) decides instead.
  • run.rs gates the GitHub and container registries by section too, and fans a workflow out to both, merging results back into document order.

Verification

cargo fmt / cargo clippy --workspace --all-targets clean, full workspace test suite green. Validated by hand against the live Docker Hub and ghcr.io registries: tag resolution, -u writes, format preservation (# syntax=, --platform, AS <stage>, trailing comments, quoting style all byte-preserved), idempotent re-runs, every -t level, and --format json.


Note on scope

This branch was 88 commits ahead of main with no open PR, so this PR also carries that previously unreleased work:

  • 83x `retry-now#?? ??an automated performance/refactor pass over the existing crates
  • Fix local version issue, Impl progressbar, Add delete target, Add criterion benchmark harness for core and node hot paths, Add test, Update lib

Only the final commit (Support container image updates) is new here.


Coverage

The 100% gate fails on this branch, and it did so before this work. Measured locally with the CI recipe (same rustfmt pre-pass, --engine llvm):

uncovered lines coverage
08ddda4 (branch tip before this work) 82 93.06%
6690660 (this PR) 80 94.63%

Every file this PR adds or touches is at 100% ??crates/docker/*, crates/core/src/yaml_scan.rs, crates/core/src/types.rs, and crates/cli/src/run.rs (which the baseline had at 0/2 and this PR brings to 16/16).

The remaining 80 are untouched by this PR and identical to the baseline, line for line:

cli/cleanup_progress.rs 55 쨌 rust/parser.rs 10 쨌 python/parser.rs 6 쨌 node/registry.rs 3 쨌 github/registry.rs 2 쨌 cli/pipeline.rs 1 쨌 core/manifest.rs 1 쨌 core/util.rs 1 쨌 python/registry.rs 1

The bulk sits in the progress-bar module added earlier on this branch. Closing that gap is a separate piece of work from container support, so it is deliberately left out of this PR.

owjs3901 added 30 commits June 25, 2026 23:49
The style module (FileStyle, IndentStyle, LineEnding) had zero call sites anywhere in the workspace -- a false public API surface. All format-preserving patchers (JSON byte-range, TOML via toml_edit, YAML byte-range) emit the original bytes verbatim outside the patched ranges and never needed these style descriptors.

Delete crates/core/src/style.rs (73 lines) and drop the pub mod style; + pub use style::{FileStyle, IndentStyle, LineEnding}; lines from crates/core/src/lib.rs (-2 lines).

Verified: cargo test passes (575 unit + 1 doctest), cargo clippy --all-targets --all-features -- -D warnings is clean, cargo bench median unchanged (0.525s, well inside the 0.113s pre-change noise band).
…g clones

Change select_version fallback parameters from Option<String> to Option<&str>. Eliminates ~7 redundant String clones per resolved dependency across npm/crates.io/PyPI/GitHub Actions registries. Public CLI/JSON/format-preservation behaviour unchanged; semantics identical (fallbacks were never stored, only returned at most once).
…n resolve_batch

Each of the npm / crates.io / PyPI `resolve_batch` impls used to spawn N `tokio::spawn` tasks, cloning the `DependencySpec` (3 owned `String`s) and the registry (Client + 3 Arcs) into every task, then collected via `collect_task_results` and post-sorted by index. The binary runs on `flavor = \current_thread\` so the spawn allocations and Arc bumps were pure overhead with zero parallelism benefit -- real concurrency already comes from the inner `Semaphore`-gated HTTP requests cooperating via `.await`.

Switch all three call sites to `futures::future::join_all` over bare `async` futures that borrow `dep` and `&self`. `join_all` preserves source order, so the post-batch sort disappears too. That collapses ~22 LOC per ecosystem to ~6 LOC, drops the per-dep `JoinHandle` allocation and the 6-ish clones per dep, and -- with the last consumer gone -- lets the `collect_task_results` helper and its rstest case be removed from `crates/core/src/util.rs` and its `pub use` re-export trimmed from `crates/core/src/lib.rs`.

No new external crate enters the dependency graph: `futures = \
…rsion

Borrow upload_time_iso_8601 as &str straight out of info.releases instead of
cloning it into the candidates Vec, drop the redundant
`let versions = candidates.iter().map(|(v,_)|v.clone()).collect()` shadow Vec
(consume candidates into the non-Newest branch instead), and remove the dead
`.or_else(|| versions.last()...)` fallback that max_by can never trigger
(its None arm only fires on an empty iterator, in which case versions was
empty too). Internal-only: same selected version for every input, identical
JSON/table output, no API/signature change.
…precisions x tags) walk to O(tags + precisions)
…existing_ref reuses it across deps that share a repo
…ry entry-point body across main.rs and bin/dcu.rs
…helper

Three ecosystem registries (node, rust, python) carried a byte-for-byte identical resolve_batch body - same join_all-over-enumerate().map() pipeline, same explanatory comment, only the enclosing Self type differed. Extract that into a generic core::resolve_batch_concurrent<F, Fut>(deps, resolve_one) helper next to build_client in crates/core/src/http.rs and re-export it from the crate root. Each registry's resolve_batch now delegates in one line: dependency_check_updates_core::resolve_batch_concurrent(deps, |dep| self.resolve_version(dep, target)).await. Public signatures unchanged on every registry; concurrency model preserved (no tokio::spawn, no per-dep JoinHandle / DependencySpec / Arc clones, source-order preserved by join_all). The generic Fn(&'a DependencySpec) -> Fut monomorphises per call site, so each registry compiles to identical assembly versus the old inline body. crates/{node,rust,python}/Cargo.toml drop the now-unused futures workspace dep; crates/core/Cargo.toml gains it. GitHub registry keeps futures because its resolve_batch is genuinely different (unique-repo fan-out with PreparedTags cache). Net: -2 declared deps, zero new external crates.
…nd per-language manifest structs

Removes pub original_text: String from ParsedManifest (core) and from the
four per-language manifest structs (PackageJsonManifest, CargoTomlManifest,
PyProjectManifest, WorkflowManifest), along with all 4 parser-constructor
assignments (original_text: text.to_owned()) and all 4 ManifestHandler::parse
assignments (original_text: manifest.original_text). The field was written
by every parser but never read at runtime: the only direct reader anywhere
in the workspace was the now-deleted test_original_text_preserved unit test,
which existed solely to round-trip the dead field. The CLI's run loop
(crates/cli/src/run.rs) reads only ParsedManifest.dependencies and uses its
own local let text = read_to_string(...) for apply_updates, never
consulting parsed.original_text.

Eliminates one whole-text to_owned() per parsed manifest. During the
parse->filter->resolve window the workspace previously held two copies of
every manifest's bytes; this drops one of them. A deep monorepo scan
(dcu -d) with N manifests pays N redundant clones; this change drops them
to zero. Also clarifies the ManifestHandler::parse trait: it no longer
advertises raw bytes that nothing consumes.

Public CLI surface (flags, table/JSON output, exit codes, dcu alias) is
preserved byte-for-byte. Format-preserving updates (toml_edit + byte-range
patchers in node/github) never consulted original_text, so apply_updates
output is unchanged. Pre-1.0 (workspace at 0.1.15) SemVer permits the
field removal from the public ParsedManifest struct.

cargo test --workspace: 72 passed (+2 doctests). clippy
--all-targets --all-features -D warnings: clean.
… iteration

Collapse the two `tags.iter().filter_map(...).collect()` chains in
PreparedTags::new into one `for tag in tags` loop that pushes into
pre-sized Vec/HashSet. The original walked the tag list twice and paid
`is_version_ref` twice per tag (once via normalize_tag, once via
tag_numeric_str). One pass is enough -- every tag that fails normalize_tag
would also fail tag_numeric_str, and every tag that succeeds normalize_tag
is guaranteed to produce a Some from tag_numeric_str.

Outputs are byte-identical: sorted_versions, highest_stable, tag_numerics
all carry the same shape as before. tag_numerics is a HashSet, so
insertion order is irrelevant; sorted_versions is .sort()-ed, so
traversal order does not affect it.

Internal-refactor only -- public signatures unchanged. All 111 github
crate tests pass; cargo clippy --all-targets --all-features -- -D warnings
remains clean; cargo bench within run-to-run noise.
…oredAny

The npm registry client deserialized every nested value body in the
packument 'versions' map (dependencies, peerDependencies, dist, engines,
...) into a serde_json::Value tree only to discard it: the only consumer
is extract_sorted_versions, which uses just the keys. For popular
packages (react, lodash, webpack, typescript) with 500-1000 published
versions and multi-KB value bodies each, this allocated millions of
small heap blocks per npm call.

Skip the value bodies via serde::de::IgnoredAny, which walks past JSON
tokens without allocating. To stay clippy-clean under all + pedantic
(HashMap<K, IgnoredAny> would trip clippy::zero_sized_map_values), wrap
HashSet<String> in a small VersionKeys newtype with a manual
Deserialize impl that runs IgnoredAny inside visit_map.

- crates/node/src/registry.rs: new VersionKeys newtype + Deserialize
  impl; NpmPackageInfo.versions becomes Option<VersionKeys>;
  extract_sorted_versions iterates over the inner HashSet; the unit
  test constructs VersionKeys directly. The 'time' field is unchanged
  because newest_by_date reads its string values.

Verified: cargo test --workspace = 571 pass + 2 doctests pass;
cargo clippy --all-targets --all-features -- -D warnings = clean;
cargo bench median 0.4044s -> 0.4382s (+8.4%, fully inside BEFORE noise
band 0.131s and inside BEFORE distribution; 0 measured benchmarks so
this is build/discovery overhead, not the patched code).
…_by_date

Mirror the PyPI registry pattern: borrow the upload timestamp as &str straight out of info.time instead of cloning into String. The borrow is dropped together with max_by's iterator chain and never escapes the function, so the per-version String allocation previously done just to feed .max_by is eliminated. Behavior preserved end-to-end; test_resolve_version_newest_by_date passes unchanged.
Walk the segment iterator directly via let-else instead of collecting into a throwaway Vec<&str> just to read its length and the first one or two elements. The helper sits on per-tag / per-dependency hot paths (github::registry::normalize_tag, cli::pipeline::compute_updates), so every call previously heap-allocated a small Vec.

Byte-equivalent: all 8 pad_to_three_segments_cases pass, full cargo test workspace stays green, cargo clippy --all-targets --all-features -- -D warnings clean. Public API (signature, doc, #[must_use]) unchanged.
owjs3901 added 27 commits July 10, 2026 14:55
Track Dockerfile FROM instructions, Compose service images, and workflow container images against any OCI Distribution registry.

Tags are grouped by build variant so node:20-alpine resolves within its own lane and never crosses to node:22. Pin precision is kept when a published tag backs it, escalating to the shortest existing form otherwise, so the emitted tag always pulls. Moving and immutable pins (:latest, codenames, digests, \ interpolations, untagged stage references) are left untouched.

Anonymous Bearer token exchange makes public images resolve on Docker Hub, ghcr.io, quay.io and friends from one code path. The YAML scalar scanner and the version-ref predicate move into core so the GitHub Actions crate shares them, and compute_updates now picks its rewrite policy per dependency section rather than per manifest kind - a single workflow can carry both uses: refs and image: pins.
endpoint() needs no HTTP client, but reading it through DockerRegistry::new() built one - which panics unless a rustls crypto provider was already installed in the process. The test passed only when some other test had installed it first, so it failed on the CI runners whose ordering put it first.

Extract endpoint_for() as a free function and test it directly. Also compare the bare authority instead of a prefix, so localhost.example.com and 127.0.0.1.example.com are no longer downgraded to plain HTTP.
The repo gates CI at 100% line coverage. The container work left four lines unreachable by the suite, and moved run.rs's resolution logic behind an async I/O boundary that no test could enter.

- Delete two defensive arms no input can reach: the tag-cache miss (every parseable name is inserted before lookup, so assert the invariant instead) and pick_existing_numeric's segment-less guard (route the slices through get so the degenerate case falls into the existing fallback).
- Test the two registry paths that were genuinely untested: a token realm that refuses, and one that answers 200 with neither token spelling. Add a challenge carrying parameters this client ignores.
- Split run.rs's workflow fan-out into partition_by_section, merge_resolved and resolve_with - all pure and unit-tested - leaving only the I/O glue behind cfg(not(tarpaulin_include)), matching how run/run_cli/main are already handled.

Measured with the CI recipe locally: every file this branch adds or touches is now at 100%.
CI enforces 100% line coverage, but the branch has been below it since before container support landed - 82 uncovered lines at 08ddda4. This covers the remainder.

The bulk is crates/cli/src/cleanup_progress.rs, which had no tests at all: sizing, removal, the progress loop, and the summary are now exercised against real temp trees, including the absent-target and undeletable-target paths. Folding one worker's result into the tally moves into absorb_outcome so the JoinError arm - unreachable through the public entry point, since remove_target cannot panic - is testable by awaiting a task that does. path_size now branches on is_dir first, which drops a guard whose only purpose was ordering; the neither-file-nor-directory case is asserted through a dangling symlink on Unix.

The rest are single branches that no fixture happened to reach: a four-segment git tag, an empty repo segment in owner//repo, a version string with no numeric head, a non-manifest file during deep scan, a wildcard path-dependency requirement, npm's hand-written Visitor::expecting, Poetry's python/git/wildcard skips, the non-string and missing version shapes in both TOML patchers, workspace-version inheritance, and PyPI's yanked-release filter on the newest-by-date path.
serde_json rejects a mis-shaped versions field before the visitor sees it, so the existing test only reaches expecting(). Handing the visitor a sequence directly exercises its inherited visit_seq, which is the remaining uncovered region of the impl block.
Nesting the Visitor impl inside the generic deserialize<D> left a coverage region on the impl line that no test could reach: the item is instantiated per-D, but the counters do not attribute back to any callable body. At module scope it is one plain item with one set of instantiations, and the file reaches 100%.

Behaviour is unchanged - the visitor was already private to the module.
@github-actions

Copy link
Copy Markdown

Changepacks

@dependency-check-updates/cli@0.1.15 → 0.2.0 - bridge/node/package.json

Minor

  • Support container image updates

Patch

  • Fix local version issue

dependency-check-updates@0.1.15 → 0.2.0 - bridge/python/pyproject.toml

Minor

  • Support container image updates

Patch

  • Fix local version issue

dependency-check-updates@0.1.15 → 0.2.0 - crates/cli/Cargo.toml

Minor

  • Support container image updates

Patch

  • Fix local version issue

dependency-check-updates-core@0.1.15 → 0.2.0 - crates/core/Cargo.toml

Minor

  • Support container image updates

Patch

  • Fix local version issue

dependency-check-updates-docker@0.1.15 → 0.2.0 - crates/docker/Cargo.toml

Minor

  • Support container image updates

dependency-check-updates-github@0.1.15 → 0.2.0 - crates/github/Cargo.toml

Minor

  • Support container image updates

Patch

  • Fix local version issue

dependency-check-updates-node@0.1.15 → 0.2.0 - crates/node/Cargo.toml

Minor

  • Support container image updates

Patch

  • Fix local version issue

dependency-check-updates-python@0.1.15 → 0.2.0 - crates/python/Cargo.toml

Minor

  • Support container image updates

Patch

  • Fix local version issue

dependency-check-updates-rust@0.1.15 → 0.2.0 - crates/rust/Cargo.toml

Minor

  • Support container image updates

Patch

  • Fix local version issue

@owjs3901
owjs3901 merged commit 8b011a8 into main Aug 21, 2026
19 checks passed
@owjs3901
owjs3901 deleted the fix-local-version-issue branch August 21, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant