Skip to content

fix(composer-update): name dev-locked intermediates so the chain can move - #50

Merged
oxyc merged 1 commit into
masterfrom
fix/composer-update-dev-locked-intermediates
Jul 30, 2026
Merged

fix(composer-update): name dev-locked intermediates so the chain can move#50
oxyc merged 1 commit into
masterfrom
fix/composer-update-dev-locked-intermediates

Conversation

@oxyc

@oxyc oxyc commented Jul 30, 2026

Copy link
Copy Markdown
Member

The gap

find_direct_ancestors walks from a flagged transitive up to its direct-dep ancestor and returns only that ancestor. Anything crossed on the way is traversed and thrown away — including packages locked to a dev-* reference.

Composer will not move a dev-locked package during a partial update unless it is named explicitly. So when the flagged package sits underneath one, listing only the direct dep leaves the intermediate pinned at its recorded commit, and the update fails on a conflict that names neither package.

Where it bit

maptilat. composer/composer was flagged for CVE-2026-59946. Its direct ancestor is wp-cli/wp-cli-bundle, so the scanner ran:

composer update -W composer/composer:~2.10.2 wp-cli/wp-cli-bundle

But the intermediate wp-cli/wp-cli sat on dev-main and wasn't listed, so it stayed pinned:

- wp-cli/wp-cli dev-main requires wp-cli/php-cli-tools ~0.13.0 -> found
  wp-cli/php-cli-tools[v0.13.0] but these were not loaded, likely because
  it conflicts with another require.

The scan failed on this every day for weeks with no fixable PR to offer — the widen step could never resolve. Adding wp-cli/wp-cli to the arg list fixed it immediately when done by hand (generoi/maptilat#52). This makes that automatic.

The change

update.sh precomputes the dev-locked set from composer.lock:

jq -r '((.packages // []) + (."packages-dev" // []))[]
  | select(.version | test("^dev-|-dev$")) | .name' composer.lock \
  > /tmp/composer-update-devlocked.txt

find_direct_ancestors emits any intermediate found in it. Two details that matter:

  • The intermediate is not terminal. The walk continues to the direct dep, which is still needed — both must be in the arg list.
  • The target is never emitted as its own ancestor. build_pkg_arg already supplies it, with its min-safe constraint attached.

Behaviour is unchanged when the file is absent or empty, so plain dependency-update runs (no vulns_json) are unaffected.

Same family as the roots/wordpress-no-content self.version case already documented in expand_args_for: the arg list has to name every package that must be free to move.

Tests

Four new assertions in lib-functions.test.sh, modelling the maptilat topology exactly:

  • empty dev-locked set → ancestor only (proves existing behaviour is untouched)
  • dev-locked intermediate surfaced alongside the direct ancestor
  • the intermediate does not stop the BFS
  • a dev-locked target is not echoed as its own ancestor

Note on the suite

composer-update/tests/run.sh reports 3 of 6 files failing both with and without this change — I verified against clean master. Those are pre-existing (jq: parse error and a vendor/composer/ClassLoader.php bootstrap failure in the semver helper tests), unrelated to this, and I have not touched them. lib-functions.test.sh passes in full.

🤖 Generated with Claude Code

…move

find_direct_ancestors walked from a flagged transitive up to its direct-dep
ancestor and returned only that ancestor. Any package crossed on the way was
traversed and discarded — including packages locked to a dev-* reference.

Composer will not move a dev-locked package during a partial update unless it
is named explicitly. So when the flagged package sits under one, listing only
the direct dep leaves the dev-locked intermediate pinned at its recorded commit
and the whole update fails on a conflict that names neither package.

maptilat hit exactly this. composer/composer was flagged for CVE-2026-59946;
its direct ancestor is wp-cli/wp-cli-bundle; but the intermediate wp-cli/wp-cli
sat on dev-main, so every attempt died with:

  wp-cli/wp-cli dev-main requires wp-cli/php-cli-tools ~0.13.0 -> found
  wp-cli/php-cli-tools[v0.13.0] but these were not loaded, likely because
  it conflicts with another require.

The scanner reported the CVE daily for weeks with no fixable PR to offer,
because the widen step could never resolve. Naming wp-cli/wp-cli fixed it
by hand; this makes that automatic.

update.sh now precomputes the dev-locked set from composer.lock into
/tmp/composer-update-devlocked.txt, and find_direct_ancestors emits any
intermediate found in it. The intermediate is NOT terminal — the walk still
continues to the direct dep, which is also needed. The target itself is never
emitted (build_pkg_arg already supplies it, with its constraint).

Behaviour is unchanged when the file is absent or empty, so a plain
dependency-update run is unaffected.

Same family as the roots/wordpress-no-content self.version case already
documented in expand_args_for: the arg list has to name every package that
must be free to move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@oxyc
oxyc merged commit b4a0d4f into master Jul 30, 2026
1 check passed
@oxyc
oxyc deleted the fix/composer-update-dev-locked-intermediates branch July 30, 2026 15:30
oxyc pushed a commit that referenced this pull request Jul 30, 2026
This branch carried

    uses: generoi/github-actions/setup@perf/npm-no-audit

which is #53's verification branch, picked up from master when this branch was
cut. #53 has since merged, so the ref is both unnecessary and wrong: leaving it
would point every consumer of the shared test workflow at a feature branch
instead of the release tag.

Restored from master. Nothing else in this branch touches test.yml — the actual
change here is confined to composer-update/tests/lib.sh.

master and the v1/v2 tags were checked and both already carry @v1, so this
never reached downstream.

Second instance of this pattern; the first was a "tmp: point internal setup ref
at this branch for verification" commit carrying setup@fix/drop-vendor-cache,
caught and reverted before #50 was opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
oxyc added a commit that referenced this pull request Jul 30, 2026
* perf(setup): disable npm audit and fund in CI

npm install is the largest remaining cost in these jobs, and it is almost
entirely the audit — a network round trip over the whole dependency tree on
every run, even when node_modules was restored intact:

  snellmanrecipes  up to date, audited 1716 packages in 48s
  beamex           up to date, audited 1880 packages in 60s
  kaskipuu         up to date, audited 1930 packages in 1m

Locally on a 1250-package tree: 9s with audit, 1s without.

Set via npm config rather than CLI flags so it covers every npm invocation,
including the ones inside projects' composer build scripts, which is where
these installs actually happen.

Dependency vulnerabilities are covered by the nightly vulnerability scan, which
gates and opens PRs. npm's install-time audit summary gates nothing.

The internal setup ref is temporarily pointed at this branch to verify; that is
reverted before merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): verify the semver cache works, not just that it exists

Three of six composer-update test files were red on master. All three had the
same cause, and none of the symptoms pointed at it.

ensure_semver_vendor caches a composer/semver vendor dir and guarded it with

    if [ ! -f "$cache/vendor/autoload.php" ]; then ... build ... fi

Existence is too weak. An interrupted `composer require` leaves autoload.php
and vendor/composer/ behind WITHOUT vendor/composer/ClassLoader.php — the local
cache here had exactly that shape, with composer/semver present but the
autoloader's own class file missing. The guard was satisfied, so the cache was
never rebuilt: every run copied a broken autoloader into the test project, the
PHP helpers fatally errored on the missing ClassLoader, and their non-JSON
output reached jq as

    jq: parse error: Invalid numeric literal at line 2, column 8

which reads like a bug in the helper's JSON, not a missing dependency. The
partial cache is also sticky — once in that state it stays broken across every
future run.

Now the guard actually exercises the cache: require the autoloader and check
Composer\Semver\Semver resolves. If it does not, the cache is deleted and
rebuilt from scratch rather than built on top of a partial tree, and the
loud-failure path reports a *working* bootstrap could not be produced.

Suite goes from 3 of 6 files failing to 6 of 6 passing, with no change to any
assertion or to the scripts under test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ci): restore setup@v1 — the temporary ref belonged to #53

This branch carried

    uses: generoi/github-actions/setup@perf/npm-no-audit

which is #53's verification branch, picked up from master when this branch was
cut. #53 has since merged, so the ref is both unnecessary and wrong: leaving it
would point every consumer of the shared test workflow at a feature branch
instead of the release tag.

Restored from master. Nothing else in this branch touches test.yml — the actual
change here is confined to composer-update/tests/lib.sh.

master and the v1/v2 tags were checked and both already carry @v1, so this
never reached downstream.

Second instance of this pattern; the first was a "tmp: point internal setup ref
at this branch for verification" commit carrying setup@fix/drop-vendor-cache,
caught and reverted before #50 was opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: test <test@example.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: genero-bot <dev@genero.fi>
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.

2 participants