Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 9 additions & 21 deletions .github/workflows/composer-update-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,12 @@ jobs:
steps:
- uses: actions/checkout@v4

# jq, git and bash are preinstalled on ubuntu-latest runners.
- name: Run shell tests
run: |
shopt -s nullglob
tests=(composer-update/tests/*.test.sh)
if [ ${#tests[@]} -eq 0 ]; then
echo "No tests found" >&2
exit 1
fi
fail=0
for t in "${tests[@]}"; do
echo "::group::$t"
if bash "$t"; then
echo "ok: $t"
else
echo "::error::$t failed"
fail=1
fi
echo "::endgroup::"
done
exit $fail
# The PHP helper tests run the real helpers and bootstrap composer/semver
# into a temp vendor dir. jq, git and bash are preinstalled on the runner.
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer

- name: Run tests
run: chmod +x composer-update/tests/run.sh && composer-update/tests/run.sh
5 changes: 4 additions & 1 deletion composer-update/scripts/compute-min-safe-constraints.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,7 @@
}
}

echo json_encode($result, JSON_UNESCAPED_SLASHES);
// Cast to object so an empty result encodes as `{}`, not `[]`. composer-update
// string-indexes this map with `jq '.[$pkg]'`, which errors on a JSON array —
// so an empty array would break the consumer under `set -eo pipefail`.
echo json_encode((object) $result, JSON_UNESCAPED_SLASHES);
149 changes: 149 additions & 0 deletions composer-update/scripts/lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
#
# Helper functions for composer-update/scripts/update.sh.
#
# Sourced by update.sh (and by the unit tests under ../tests). These functions
# are pure-ish: they take args and read a few tempfiles that update.sh writes
# before calling them:
# /tmp/composer-update-constraints.json (build_pkg_arg, build_widen_arg)
# /tmp/composer-update-direct.txt (find_direct_ancestors, expand_args_for)
# /tmp/composer-update-reverse.txt (find_direct_ancestors)
# /tmp/composer-update-vulns.json (is_still_vulnerable)
# and $GITHUB_ACTION_PATH for the PHP helpers.

# Build the composer arg for a single package: just the name if no
# constraint is configured for it, or `name:constraint` (e.g.
# `vendor/pkg:~1.2.3`) when the caller supplied a tight bound.
# Tilde at offset >0 inside a single word isn't subject to shell
# tilde expansion, so the colon-form is safe to interpolate.
# Trailing newline matters: expand_args_for() concatenates this
# with find_direct_ancestors() and pipes the result to `while read`;
# without it, a direct-dep package produces an unterminated line
# that `read` discards and the arg ends up empty. (#27)
build_pkg_arg() {
local pkg="$1"
local c
c=$(jq -r --arg p "$pkg" '.[$p] // ""' /tmp/composer-update-constraints.json)
if [ -n "$c" ]; then
printf '%s:%s\n' "$pkg" "$c"
else
printf '%s\n' "$pkg"
fi
}

# Build the composer arg for the WIDEN step (composer require), which
# REPLACES the project's constraint rather than tightening within it.
# Here the package gets a caret `^min_safe` (e.g. `^27.1.2` =
# `>=27.1.2,<28.0.0`) instead of the patch-pinned tight `~min_safe`.
# Why: `composer require` is reached only when the in-constraint
# update already failed — i.e. the fix lives OUTSIDE composer.json's
# current constraint (e.g. project pins `^26.0` but the patched
# release is 27.x). A tight `~27.1.2` would (a) pin to a single patch
# line that often doesn't exist (Yoast went 27.1.1 → 27.2, no 27.1.2)
# and (b) defeat the whole point of widening. The caret spans the
# safe-version's whole major, so composer can land on the real fix
# (27.6) while still not crossing into the next major. Packages with
# no min_safe (no vulns_json) fall back to a bare name — unconstrained
# widening, as before. (#29)
build_widen_arg() {
local pkg="$1"
local c
c=$(jq -r --arg p "$pkg" '.[$p] // ""' /tmp/composer-update-constraints.json)
if [[ "$c" =~ ^~([0-9.]+)$ ]]; then
printf '%s:^%s' "$pkg" "${BASH_REMATCH[1]}"
else
printf '%s' "$pkg"
fi
}

# Find direct-dep ancestor(s) of a package by BFS through the reverse
# map. Returns one ancestor per line. If the package is itself a
# direct dep, returns just that name. (#22, #26)
find_direct_ancestors() {
local target="$1"
local seen="|"
local queue="$target"
local result=""
while [ -n "$queue" ]; do
local current
current=$(echo "$queue" | head -1)
queue=$(echo "$queue" | tail -n +2)
case "$seen" in *"|$current|"*) continue ;; esac
seen="$seen$current|"
if grep -qFx "$current" /tmp/composer-update-direct.txt; then
result="$result $current"
continue
fi
local parents
parents=$(awk -v t="$current" '$1 == t {print $2}' /tmp/composer-update-reverse.txt)
if [ -n "$parents" ]; then
queue=$(printf '%s\n%s' "$queue" "$parents")
fi
done
echo "$result" | tr ' ' '\n' | grep -v '^$' | sort -u || true
}

# Derive a "same-major, not-affected" loose constraint from the tight
# `~X.Y.Z` form: `>=X.Y.Z, <(X+1).0.0`. Used as a fallback when the
# tight retry matches no published version (e.g. CVE upper bound
# `<=6.6.3` heuristic'd to `~6.6.4`, but WordPress never tagged
# 6.6.4 — the next release is 6.7.0). Lets composer pick the next
# safe version within composer.json's existing constraint rather
# than falling through to widening. (#28)
loosen_constraint() {
local tight="$1"
if [[ "$tight" =~ ^~([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
local major="${BASH_REMATCH[1]}"
local minor="${BASH_REMATCH[2]}"
local patch="${BASH_REMATCH[3]}"
printf '>=%s.%s.%s,<%s.0.0' "$major" "$minor" "$patch" "$((major + 1))"
fi
}

# Safety net for the loose retry: confirm the package's new locked
# version is OUT of every range in its `affected` field before
# accepting the update. composer audit's advisory database normally
# blocks vulnerable versions, but `audit.block-insecure` defaults
# vary by project and we'd rather over-revert than ship a "fix"
# that doesn't fix anything. (#28)
is_still_vulnerable() {
local pkg="$1"
local version="$2"
if [ ! -s /tmp/composer-update-vulns.json ] || [ -z "$version" ]; then
echo no
return
fi
php "$GITHUB_ACTION_PATH/scripts/is-still-vulnerable.php" \
/tmp/composer-update-vulns.json "$pkg" "$version"
}

# Expand a single flagged package into the args we pass to composer:
# `name[:constraint]` for the package itself, plus the names of its
# direct-dep ancestor(s) when the flagged package is a transitive.
#
# Why: `composer update -W X` updates X and X's dependencies (downward),
# but NOT X's reverse-deps. For metapackages like roots/wordpress that
# pin roots/wordpress-no-content at self.version, the parent is locked
# at the same version as the transitive and won't move unless we list
# it explicitly. Without this expansion, updating wordpress-no-content
# within a tight ~constraint fails with "roots/wordpress is locked and
# not requested" and falls through to widening — which then rewrites
# composer.json unnecessarily.
#
# Ancestors get no constraint suffix: we only want them eligible for
# movement within their existing composer.json constraint. (#26)
expand_args_for() {
local pkg="$1"
build_pkg_arg "$pkg"
if ! grep -qFx "$pkg" /tmp/composer-update-direct.txt; then
find_direct_ancestors "$pkg"
fi
}

# Read a package's locked version from a given composer.lock file.
get_lock_version() {
jq -r --arg p "$1" '
((.packages // []) + (."packages-dev" // []))
| map(select(.name == $p)) | first | .version // empty
' "$2"
}
137 changes: 4 additions & 133 deletions composer-update/scripts/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
# GITHUB_ACTION_PATH and GITHUB_OUTPUT. Mirrors the composite shell options.
set -eo pipefail

# Shared helper functions (build_pkg_arg, build_widen_arg, find_direct_ancestors,
# loosen_constraint, is_still_vulnerable, expand_args_for, get_lock_version).
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

# Snapshot composer.lock to a tempfile BEFORE any composer ops run,
# so we have an untouched copy for the before/after version diff.
cp composer.lock /tmp/composer-update-before.lock
Expand All @@ -29,50 +33,6 @@ if [ -n "${VULNS_JSON:-}" ]; then
fi
fi

# Build the composer arg for a single package: just the name if no
# constraint is configured for it, or `name:constraint` (e.g.
# `vendor/pkg:~1.2.3`) when the caller supplied a tight bound.
# Tilde at offset >0 inside a single word isn't subject to shell
# tilde expansion, so the colon-form is safe to interpolate.
# Trailing newline matters: expand_args_for() concatenates this
# with find_direct_ancestors() and pipes the result to `while read`;
# without it, a direct-dep package produces an unterminated line
# that `read` discards and the arg ends up empty.
build_pkg_arg() {
local pkg="$1"
local c
c=$(jq -r --arg p "$pkg" '.[$p] // ""' /tmp/composer-update-constraints.json)
if [ -n "$c" ]; then
printf '%s:%s\n' "$pkg" "$c"
else
printf '%s\n' "$pkg"
fi
}

# Build the composer arg for the WIDEN step (composer require), which
# REPLACES the project's constraint rather than tightening within it.
# Here the package gets a caret `^min_safe` (e.g. `^27.1.2` =
# `>=27.1.2,<28.0.0`) instead of the patch-pinned tight `~min_safe`.
# Why: `composer require` is reached only when the in-constraint
# update already failed — i.e. the fix lives OUTSIDE composer.json's
# current constraint (e.g. project pins `^26.0` but the patched
# release is 27.x). A tight `~27.1.2` would (a) pin to a single patch
# line that often doesn't exist (Yoast went 27.1.1 → 27.2, no 27.1.2)
# and (b) defeat the whole point of widening. The caret spans the
# safe-version's whole major, so composer can land on the real fix
# (27.6) while still not crossing into the next major. Packages with
# no min_safe (no vulns_json) fall back to a bare name — unconstrained
# widening, as before.
build_widen_arg() {
local pkg="$1"
local c
c=$(jq -r --arg p "$pkg" '.[$p] // ""' /tmp/composer-update-constraints.json)
if [[ "$c" =~ ^~([0-9.]+)$ ]]; then
printf '%s:^%s' "$pkg" "${BASH_REMATCH[1]}"
else
printf '%s' "$pkg"
fi
}

# Precompute the project's direct-dependency set + the lock's reverse-
# dependency map, used by:
Expand All @@ -89,89 +49,6 @@ jq -r '
"\($child) \($p.name)"
' composer.lock > /tmp/composer-update-reverse.txt

# Find direct-dep ancestor(s) of a package by BFS through the reverse
# map. Returns one ancestor per line. If the package is itself a
# direct dep, returns just that name.
find_direct_ancestors() {
local target="$1"
local seen="|"
local queue="$target"
local result=""
while [ -n "$queue" ]; do
local current
current=$(echo "$queue" | head -1)
queue=$(echo "$queue" | tail -n +2)
case "$seen" in *"|$current|"*) continue ;; esac
seen="$seen$current|"
if grep -qFx "$current" /tmp/composer-update-direct.txt; then
result="$result $current"
continue
fi
local parents
parents=$(awk -v t="$current" '$1 == t {print $2}' /tmp/composer-update-reverse.txt)
if [ -n "$parents" ]; then
queue=$(printf '%s\n%s' "$queue" "$parents")
fi
done
echo "$result" | tr ' ' '\n' | grep -v '^$' | sort -u || true
}

# Derive a "same-major, not-affected" loose constraint from the tight
# `~X.Y.Z` form: `>=X.Y.Z, <(X+1).0.0`. Used as a fallback when the
# tight retry matches no published version (e.g. CVE upper bound
# `<=6.6.3` heuristic'd to `~6.6.4`, but WordPress never tagged
# 6.6.4 — the next release is 6.7.0). Lets composer pick the next
# safe version within composer.json's existing constraint rather
# than falling through to widening.
loosen_constraint() {
local tight="$1"
if [[ "$tight" =~ ^~([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
local major="${BASH_REMATCH[1]}"
local minor="${BASH_REMATCH[2]}"
local patch="${BASH_REMATCH[3]}"
printf '>=%s.%s.%s,<%s.0.0' "$major" "$minor" "$patch" "$((major + 1))"
fi
}

# Safety net for the loose retry: confirm the package's new locked
# version is OUT of every range in its `affected` field before
# accepting the update. composer audit's advisory database normally
# blocks vulnerable versions, but `audit.block-insecure` defaults
# vary by project and we'd rather over-revert than ship a "fix"
# that doesn't fix anything.
is_still_vulnerable() {
local pkg="$1"
local version="$2"
if [ ! -s /tmp/composer-update-vulns.json ] || [ -z "$version" ]; then
echo no
return
fi
php "$GITHUB_ACTION_PATH/scripts/is-still-vulnerable.php" \
/tmp/composer-update-vulns.json "$pkg" "$version"
}

# Expand a single flagged package into the args we pass to composer:
# `name[:constraint]` for the package itself, plus the names of its
# direct-dep ancestor(s) when the flagged package is a transitive.
#
# Why: `composer update -W X` updates X and X's dependencies (downward),
# but NOT X's reverse-deps. For metapackages like roots/wordpress that
# pin roots/wordpress-no-content at self.version, the parent is locked
# at the same version as the transitive and won't move unless we list
# it explicitly. Without this expansion, updating wordpress-no-content
# within a tight ~constraint fails with "roots/wordpress is locked and
# not requested" and falls through to widening — which then rewrites
# composer.json unnecessarily.
#
# Ancestors get no constraint suffix: we only want them eligible for
# movement within their existing composer.json constraint.
expand_args_for() {
local pkg="$1"
build_pkg_arg "$pkg"
if ! grep -qFx "$pkg" /tmp/composer-update-direct.txt; then
find_direct_ancestors "$pkg"
fi
}

# Step 1: Try composer update (stays within existing constraints).
# `-W` (--with-all-dependencies) lets composer also update packages that
Expand Down Expand Up @@ -207,12 +84,6 @@ fi
# per-package: for any package whose locked version didn't move,
# retry the update on just that one so a single unfixable package
# can't poison the rest.
get_lock_version() {
jq -r --arg p "$1" '
((.packages // []) + (."packages-dev" // []))
| map(select(.name == $p)) | first | .version // empty
' "$2"
}

UNHANDLED=""
for PACKAGE in $PACKAGES; do
Expand Down
Loading
Loading