From b16b129119413010e5e2b01d9f66272ef506fd09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Sch=C3=B6ldstr=C3=B6m?= Date: Thu, 21 May 2026 09:05:24 -0300 Subject: [PATCH] fix(composer-update): extract update logic to a script; fix no-widen abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, one root area: 1. The inline "Update packages" run block grew past GitHub's 21000-char compiled-expression limit (`The template is not valid ... Exceeded max expression length 21000`), so the action failed to even start — breaking every repo's scheduled scan. Extract the ~470-line block to composer-update/scripts/update.sh, invoked via `bash "$GITHUB_ACTION_PATH/scripts/update.sh"`. Inputs pass through env (PACKAGES, VULNS_JSON); the script keeps `set -eo pipefail` to mirror the composite shell. No more inline-expression size ceiling, and the logic is now unit-testable. 2. When every constraint-blocked package is in extra.vuln-scan.no-widen, the widen-targets dedupe ran `grep -v '^$'` over empty input, which exits 1 and (under pipefail) aborted the step — dropping the PR even when other packages updated cleanly. Guard with `|| true` (same fix on find_direct_ancestors, whose empty-result grep had the identical property). Add a regression test (composer-update/tests/no-widen-still-creates-pr.test.sh) that drives the real script with a fake composer and asserts a safe package still updates while a no-widen package is skipped without aborting. Verified it fails on the pre-fix logic and passes after. Wire it into CI via .github/workflows/composer-update-tests.yml. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/composer-update-tests.yml | 44 ++ composer-update/action.yml | 487 +----------------- composer-update/scripts/update.sh | 483 +++++++++++++++++ .../tests/no-widen-still-creates-pr.test.sh | 99 ++++ 4 files changed, 628 insertions(+), 485 deletions(-) create mode 100644 .github/workflows/composer-update-tests.yml create mode 100755 composer-update/scripts/update.sh create mode 100755 composer-update/tests/no-widen-still-creates-pr.test.sh diff --git a/.github/workflows/composer-update-tests.yml b/.github/workflows/composer-update-tests.yml new file mode 100644 index 0000000..9be6ba8 --- /dev/null +++ b/.github/workflows/composer-update-tests.yml @@ -0,0 +1,44 @@ +name: "Test: composer-update" + +on: + pull_request: + paths: + - 'composer-update/**' + - '.github/workflows/composer-update-tests.yml' + push: + branches: [master] + paths: + - 'composer-update/**' + - '.github/workflows/composer-update-tests.yml' + +permissions: + contents: read + +jobs: + test: + name: Run composer-update tests + runs-on: ubuntu-latest + 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 diff --git a/composer-update/action.yml b/composer-update/action.yml index f69f98f..efaade8 100644 --- a/composer-update/action.yml +++ b/composer-update/action.yml @@ -104,491 +104,8 @@ runs: shell: bash env: VULNS_JSON: ${{ inputs.vulns_json }} - run: | - # 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 - - # Derive minimum-safe constraints from the optional vulns_json input. - # The PHP helper parses each vuln's `affected` range, finds the one - # that contains the currently-locked version, and emits a tight - # `~X.Y.Z` constraint capping at patch-level. Empty input → empty - # constraint map → unconstrained behavior throughout. - echo '{}' > /tmp/composer-update-constraints.json - if [ -n "${VULNS_JSON:-}" ]; then - printf '%s' "$VULNS_JSON" > /tmp/composer-update-vulns.json - if jq -e 'type == "array"' /tmp/composer-update-vulns.json >/dev/null 2>&1; then - php "${{ github.action_path }}/scripts/compute-min-safe-constraints.php" \ - /tmp/composer-update-vulns.json composer.lock \ - > /tmp/composer-update-constraints.json - echo "Minimum-safe constraints:" - jq . /tmp/composer-update-constraints.json - else - echo "::warning::vulns_json input is not a valid JSON array — falling back to unconstrained behavior" - 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_args_for() below to include the direct-dep ancestor(s) of - # each flagged transitive in the composer update call, so the - # ancestor can move within its existing composer.json constraint - # - the widen step (Step 2) below, to BFS from each unhandled - # transitive up to a direct-dep ancestor it can widen - jq -r '((.require // {}) | keys) + ((.["require-dev"] // {}) | keys) | .[]' composer.json \ - | sort -u > /tmp/composer-update-direct.txt - jq -r ' - ((.packages // []) + (."packages-dev" // []))[] as $p | - ($p.require // {} | keys[]) as $child | - "\($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 - # `|| true`: an empty result (no direct-dep ancestor found) is a - # valid outcome, not an error — but `grep -v '^$'` exits 1 on no - # match, which under `set -eo pipefail` would propagate out of the - # `$(...)`/pipe at the call sites and abort the step. Callers already - # handle an empty list, so return cleanly here. - 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 - # are LOCKED (not just those listed) when needed — required for cases - # like roots/wordpress-no-content, which is pinned to the same version - # as roots/wordpress; without -W neither can move. - # Composite actions run with `set -e`, so capture the exit code via - # `|| UPDATE_EXIT=$?` to keep the fallback alive on a non-zero exit. - declare -a BULK_ARGS=() - declare -A BULK_SEEN=() - for PACKAGE in ${{ inputs.packages }}; do - while IFS= read -r arg; do - [ -z "$arg" ] && continue - [ -n "${BULK_SEEN[$arg]:-}" ] && continue - BULK_SEEN[$arg]=1 - BULK_ARGS+=("$arg") - done < <(expand_args_for "$PACKAGE") - done - echo "Trying: composer update -W ${BULK_ARGS[*]}" - UPDATE_EXIT=0 - composer update -W "${BULK_ARGS[@]}" --no-interaction --no-scripts 2>&1 || UPDATE_EXIT=$? - if [ "$UPDATE_EXIT" -ne 0 ]; then - echo "::warning::bulk composer update exited with code $UPDATE_EXIT — will retry per package" - fi - - # Per-package retry. `composer update -W ` is transactional — - # if even ONE listed package can't reach a satisfying version within - # constraints (e.g. faq-schema-block-to-accordion whose only fix is - # on dev-trunk, which we reject), composer rolls back the entire - # batch and leaves the lock untouched. That would punish packages - # like phpunit/phpunit whose fix IS available within their current - # constraint, by forcing them through the widening step. Loop - # 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 ${{ inputs.packages }}; do - BEFORE_V=$(get_lock_version "$PACKAGE" /tmp/composer-update-before.lock) - CURRENT_V=$(get_lock_version "$PACKAGE" composer.lock) - if [ -n "$CURRENT_V" ] && [ "$BEFORE_V" != "$CURRENT_V" ]; then - continue - fi - declare -a RETRY_ARGS=() - while IFS= read -r arg; do - [ -n "$arg" ] && RETRY_ARGS+=("$arg") - done < <(expand_args_for "$PACKAGE") - echo " retrying per-package (tight): composer update -W ${RETRY_ARGS[*]}" - composer update -W "${RETRY_ARGS[@]}" --no-interaction --no-scripts 2>&1 || true - RETRY_V=$(get_lock_version "$PACKAGE" composer.lock) - if [ -n "$RETRY_V" ] && [ "$BEFORE_V" != "$RETRY_V" ]; then - continue - fi - - # Tight retry produced no movement — either the constraint is - # outside composer.json's range (genuine widen-needed case), or - # the tight ~X.Y.Z range has no published version (e.g. - # WP never tagged 6.6.4, only 6.7+). For the second case we - # can still avoid widening composer.json by using a loose - # constraint `>=X.Y.Z, <(X+1).0.0` which lets composer pick - # the next safe version within the project's existing - # constraint. The first case will also try this and still - # fail — falling through to the widen step as before. - TIGHT=$(jq -r --arg p "$PACKAGE" '.[$p] // ""' /tmp/composer-update-constraints.json) - LOOSE=$(loosen_constraint "$TIGHT") - if [ -n "$LOOSE" ]; then - declare -a LOOSE_ARGS=("$PACKAGE:$LOOSE") - if ! grep -qFx "$PACKAGE" /tmp/composer-update-direct.txt; then - while IFS= read -r anc; do - [ -n "$anc" ] && LOOSE_ARGS+=("$anc") - done < <(find_direct_ancestors "$PACKAGE") - fi - - # Snapshot composer.json + composer.lock so we can revert if - # the loose retry moves the package to a still-vulnerable - # version. (composer's own audit-blocking usually prevents - # this, but we don't rely on it.) - cp composer.json /tmp/composer-update-pre-loose.json - cp composer.lock /tmp/composer-update-pre-loose.lock - - echo " retrying per-package (loose): composer update -W ${LOOSE_ARGS[*]}" - composer update -W "${LOOSE_ARGS[@]}" --no-interaction --no-scripts 2>&1 || true - LOOSE_V=$(get_lock_version "$PACKAGE" composer.lock) - if [ -n "$LOOSE_V" ] && [ "$BEFORE_V" != "$LOOSE_V" ]; then - if [ "$(is_still_vulnerable "$PACKAGE" "$LOOSE_V")" = "no" ]; then - echo " loose retry succeeded: $PACKAGE $BEFORE_V → $LOOSE_V (out of affected range, composer.json untouched)" - continue - else - echo "::warning::loose retry moved $PACKAGE to $LOOSE_V but it's still in the affected range — reverting" - cp /tmp/composer-update-pre-loose.json composer.json - cp /tmp/composer-update-pre-loose.lock composer.lock - fi - fi - fi - - UNHANDLED="$UNHANDLED $PACKAGE" - done - - if [ -z "$UNHANDLED" ]; then - echo "Updated all packages within existing constraints" - echo "changed=true" >> "$GITHUB_OUTPUT" - else - echo "Packages still needing a constraint bump:$UNHANDLED" - # Step 2: Constraints blocked the update — try composer require - # (bumps to latest, REPLACING the existing constraint). Same `-W` - # reasoning applies. - # - # Opt-out: packages listed under `extra.vuln-scan.no-widen` in the - # project's composer.json are skipped here. Use this for packages - # whose constraints are intentional (exact pin like "10.0.2", a - # locked-down range like "^1.1" the team won't widen automatically, - # license-tested version, etc.). The map's values are free-text - # reasons — they're not parsed by this action, they exist purely - # for in-place documentation of why each package opts out. - # - # Format (both shapes accepted): - # "extra": { "vuln-scan": { "no-widen": { - # "wpackagist-plugin/woocommerce": "License-tested only against 10.0.x" - # } } } - # or: - # "extra": { "vuln-scan": { "no-widen": [ - # "wpackagist-plugin/woocommerce" - # ] } } - NO_WIDEN=$(jq -r ' - .extra["vuln-scan"]["no-widen"] // {} | - if type == "array" then .[] else keys[] end - ' composer.json 2>/dev/null | tr '\n' ' ') - echo "No update within constraints, trying: composer require -W (filtered against extra.vuln-scan.no-widen)" - - # Expand the unhandled package list into the set of direct deps - # we'll actually widen. Packages already moved by the per-package - # update retry are NOT in $UNHANDLED, so they don't reach this - # step and won't have their constraint widened unnecessarily. - # Transitives get replaced by their direct-dep ancestor(s) via - # find_direct_ancestors (defined at the top of this step); - # multiple flagged packages converging on the same ancestor are - # de-duped so we only widen each ancestor once. - TARGETS="" - for PACKAGE in $UNHANDLED; do - if echo " $NO_WIDEN " | grep -qF " $PACKAGE "; then - echo " skip $PACKAGE — listed in extra.vuln-scan.no-widen" - continue - fi - - if grep -qFx "$PACKAGE" /tmp/composer-update-direct.txt; then - TARGETS="$TARGETS $PACKAGE" - continue - fi - - ancestors=$(find_direct_ancestors "$PACKAGE" | tr '\n' ' ') - if [ -z "${ancestors// /}" ]; then - echo "::warning::no direct-dep ancestor found for transitive $PACKAGE — cannot widen" - continue - fi - echo " $PACKAGE is transitive — will widen ancestor(s): $ancestors" - TARGETS="$TARGETS $ancestors" - done - - # Dedupe. `|| true`: when every unhandled package was skipped via - # no-widen, TARGETS is empty and `grep -v '^$'` matches nothing, - # exiting 1 — which under `set -eo pipefail` would kill the whole - # step and abort PR creation, even when Step 1 already moved other - # packages within constraints. Swallow the empty-match exit so we - # fall through to the git-diff check below and still raise a PR for - # whatever did update. - TARGETS=$(echo "$TARGETS" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' || true) - - for PACKAGE in $TARGETS; do - # Snapshot composer.json + composer.lock before each require so we - # can roll back if composer resolves the package to a dev-* branch - # constraint (e.g. dev-trunk). That happens on wpackagist plugins - # when the project sets minimum-stability: dev and no released tag - # outranks trunk — never a safe answer for a vuln scanner. - cp composer.json /tmp/composer-update-pre-require.json - cp composer.lock /tmp/composer-update-pre-require.lock - - # Preserve the require / require-dev placement. `composer require` - # defaults to `require`; without `--dev` it will MOVE a package - # that was originally in require-dev (e.g. phpunit/phpunit) into - # require, which silently changes the prod dependency surface. - REQUIRE_FLAGS="-W" - if jq -e --arg p "$PACKAGE" '(.["require-dev"] // {})[$p]' composer.json >/dev/null; then - REQUIRE_FLAGS="-W --dev" - fi - - BEFORE_REQUIRE_V=$(get_lock_version "$PACKAGE" /tmp/composer-update-pre-require.lock) - PKG_ARG=$(build_widen_arg "$PACKAGE") - - echo " widening: composer require $REQUIRE_FLAGS $PKG_ARG" - composer require $REQUIRE_FLAGS "$PKG_ARG" --no-interaction --no-scripts 2>&1 || true - - NEW_CONSTRAINT=$(jq -r --arg p "$PACKAGE" '(.require // {})[$p] // (.["require-dev"] // {})[$p] // ""' composer.json) - AFTER_REQUIRE_V=$(get_lock_version "$PACKAGE" composer.lock) - - # Two reasons to throw out the require outcome and restore the - # snapshot: - # - # 1. dev-* constraint — wpackagist plugins whose trunk readme - # declares a version higher than the latest stable tag can - # resolve to e.g. dev-trunk; never a safe answer for a vuln - # scanner. - # - # 2. Downgrade — `composer require -W ` with no constraint - # can pick a LOWER version than the one currently locked - # when audit.block-insecure blocks all sub-deps of newer - # versions (e.g. roots/wordpress 6.x metapackages all - # require roots/wordpress-no-content self.version, which is - # audit-blocked, so composer walks back to roots/wordpress - # 5.9.3 which predates the metapackage split). A fix-by- - # downgrade is never the right answer; surface it as a - # triage signal instead. - REVERT_REASON="" - case "$NEW_CONSTRAINT" in - dev-*) - REVERT_REASON="resolved to dev branch constraint '$NEW_CONSTRAINT'" - ;; - esac - if [ -z "$REVERT_REASON" ] && [ -n "$BEFORE_REQUIRE_V" ] && [ -n "$AFTER_REQUIRE_V" ] && [ "$BEFORE_REQUIRE_V" != "$AFTER_REQUIRE_V" ]; then - LOWER=$(printf '%s\n%s\n' "$BEFORE_REQUIRE_V" "$AFTER_REQUIRE_V" | sort -V | head -1) - if [ "$LOWER" = "$AFTER_REQUIRE_V" ]; then - REVERT_REASON="downgrade $BEFORE_REQUIRE_V → $AFTER_REQUIRE_V (likely blocked by audit advisory on a sub-dep)" - fi - fi - - if [ -n "$REVERT_REASON" ]; then - echo "::warning::composer require on $PACKAGE — reverting: $REVERT_REASON" - cp /tmp/composer-update-pre-require.json composer.json - cp /tmp/composer-update-pre-require.lock composer.lock - fi - done - - if git diff --quiet composer.json composer.lock; then - echo "No updates available" - echo "changed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "Updated with constraint bump" - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - # Build a version-diff table from the before tempfile (saved above) - # vs the post-update composer.lock. - # from_entries requires {key, value} — it silently drops other field - # names like `version`, producing a map of {pkg: null} regardless of - # actual version. Map to {key, value} explicitly. - JQ_VERSIONS='[(.packages + ."packages-dev")[] | {key: .name, value: .version}] | from_entries' - jq "$JQ_VERSIONS" /tmp/composer-update-before.lock > /tmp/composer-update-before.json - jq "$JQ_VERSIONS" composer.lock > /tmp/composer-update-after.json - - echo "Before snapshot: $(jq 'length' /tmp/composer-update-before.json) packages" - echo "After snapshot: $(jq 'length' /tmp/composer-update-after.json) packages" - - # Count packages with actual version changes (or added/removed). - # If zero, the composer.lock diff is metadata-only (refreshed dist - # references, content-hash) and doesn't actually fix any vulnerability - # — skip PR creation rather than opening noise. - VERSION_DIFF_COUNT=$(jq -r --slurpfile after /tmp/composer-update-after.json ' - ([to_entries[] - | ($after[0][.key] // null) as $n - | select($n != null and $n != .value) - ] | length) - + ([$after[0] | keys[]] - [keys[]] | length) - + ([keys[]] - [$after[0] | keys[]] | length) - ' /tmp/composer-update-before.json) - - echo "Version changes detected: $VERSION_DIFF_COUNT" - - if [ "$VERSION_DIFF_COUNT" -eq 0 ]; then - echo "composer.lock changed but no package versions moved — skipping PR" - echo "changed=false" >> "$GITHUB_OUTPUT" - # Reset lock to avoid leaving metadata-only changes staged - git checkout -- composer.json composer.lock 2>/dev/null || true - exit 0 - fi - - { - echo "" - echo "## Version changes" - echo "" - echo "| Package | From | To |" - echo "|---|---|---|" - jq -r --slurpfile after /tmp/composer-update-after.json ' - to_entries[] as $b - | ($after[0][$b.key] // null) as $n - | select($n != null and $n != $b.value) - | "| `\($b.key)` | `\($b.value)` | `\($n)` |" - ' /tmp/composer-update-before.json | sort - # New packages (present in after but not before) - jq -r --slurpfile after /tmp/composer-update-after.json ' - [$after[0] | keys[]] - [keys[]] | .[] - ' /tmp/composer-update-before.json | while read -r pkg; do - [ -z "$pkg" ] && continue - v=$(jq -r --arg k "$pkg" '.[$k]' /tmp/composer-update-after.json) - echo "| \`$pkg\` | _(new)_ | \`$v\` |" - done - # Removed packages (present in before but not after) - jq -r --slurpfile after /tmp/composer-update-after.json ' - [keys[]] - [$after[0] | keys[]] | .[] - ' /tmp/composer-update-before.json | while read -r pkg; do - [ -z "$pkg" ] && continue - v=$(jq -r --arg k "$pkg" '.[$k]' /tmp/composer-update-before.json) - echo "| \`$pkg\` | \`$v\` | _(removed)_ |" - done - } > /tmp/composer-update-versions.md - - echo "Version changes table:" - cat /tmp/composer-update-versions.md + PACKAGES: ${{ inputs.packages }} + run: bash "$GITHUB_ACTION_PATH/scripts/update.sh" - name: Create PR if: steps.check.outputs.skip != 'true' && steps.update.outputs.changed == 'true' diff --git a/composer-update/scripts/update.sh b/composer-update/scripts/update.sh new file mode 100755 index 0000000..76d91bb --- /dev/null +++ b/composer-update/scripts/update.sh @@ -0,0 +1,483 @@ +#!/usr/bin/env bash +# Update vulnerable Composer packages within constraints (composer update), +# then widen (composer require) for any not in extra.vuln-scan.no-widen. +# Extracted from action.yml (inline run blocks have a 21000-char compiled- +# expression cap). Inputs arrive via env: PACKAGES, VULNS_JSON; GitHub sets +# GITHUB_ACTION_PATH and GITHUB_OUTPUT. Mirrors the composite shell options. +set -eo pipefail + +# 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 + +# Derive minimum-safe constraints from the optional vulns_json input. +# The PHP helper parses each vuln's `affected` range, finds the one +# that contains the currently-locked version, and emits a tight +# `~X.Y.Z` constraint capping at patch-level. Empty input → empty +# constraint map → unconstrained behavior throughout. +echo '{}' > /tmp/composer-update-constraints.json +if [ -n "${VULNS_JSON:-}" ]; then + printf '%s' "$VULNS_JSON" > /tmp/composer-update-vulns.json + if jq -e 'type == "array"' /tmp/composer-update-vulns.json >/dev/null 2>&1; then + php "$GITHUB_ACTION_PATH/scripts/compute-min-safe-constraints.php" \ + /tmp/composer-update-vulns.json composer.lock \ + > /tmp/composer-update-constraints.json + echo "Minimum-safe constraints:" + jq . /tmp/composer-update-constraints.json + else + echo "::warning::vulns_json input is not a valid JSON array — falling back to unconstrained behavior" + 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_args_for() below to include the direct-dep ancestor(s) of +# each flagged transitive in the composer update call, so the +# ancestor can move within its existing composer.json constraint +# - the widen step (Step 2) below, to BFS from each unhandled +# transitive up to a direct-dep ancestor it can widen +jq -r '((.require // {}) | keys) + ((.["require-dev"] // {}) | keys) | .[]' composer.json \ + | sort -u > /tmp/composer-update-direct.txt +jq -r ' + ((.packages // []) + (."packages-dev" // []))[] as $p | + ($p.require // {} | keys[]) as $child | + "\($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 +# are LOCKED (not just those listed) when needed — required for cases +# like roots/wordpress-no-content, which is pinned to the same version +# as roots/wordpress; without -W neither can move. +# Composite actions run with `set -e`, so capture the exit code via +# `|| UPDATE_EXIT=$?` to keep the fallback alive on a non-zero exit. +declare -a BULK_ARGS=() +declare -A BULK_SEEN=() +for PACKAGE in $PACKAGES; do + while IFS= read -r arg; do + [ -z "$arg" ] && continue + [ -n "${BULK_SEEN[$arg]:-}" ] && continue + BULK_SEEN[$arg]=1 + BULK_ARGS+=("$arg") + done < <(expand_args_for "$PACKAGE") +done +echo "Trying: composer update -W ${BULK_ARGS[*]}" +UPDATE_EXIT=0 +composer update -W "${BULK_ARGS[@]}" --no-interaction --no-scripts 2>&1 || UPDATE_EXIT=$? +if [ "$UPDATE_EXIT" -ne 0 ]; then + echo "::warning::bulk composer update exited with code $UPDATE_EXIT — will retry per package" +fi + +# Per-package retry. `composer update -W ` is transactional — +# if even ONE listed package can't reach a satisfying version within +# constraints (e.g. faq-schema-block-to-accordion whose only fix is +# on dev-trunk, which we reject), composer rolls back the entire +# batch and leaves the lock untouched. That would punish packages +# like phpunit/phpunit whose fix IS available within their current +# constraint, by forcing them through the widening step. Loop +# 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 + BEFORE_V=$(get_lock_version "$PACKAGE" /tmp/composer-update-before.lock) + CURRENT_V=$(get_lock_version "$PACKAGE" composer.lock) + if [ -n "$CURRENT_V" ] && [ "$BEFORE_V" != "$CURRENT_V" ]; then + continue + fi + declare -a RETRY_ARGS=() + while IFS= read -r arg; do + [ -n "$arg" ] && RETRY_ARGS+=("$arg") + done < <(expand_args_for "$PACKAGE") + echo " retrying per-package (tight): composer update -W ${RETRY_ARGS[*]}" + composer update -W "${RETRY_ARGS[@]}" --no-interaction --no-scripts 2>&1 || true + RETRY_V=$(get_lock_version "$PACKAGE" composer.lock) + if [ -n "$RETRY_V" ] && [ "$BEFORE_V" != "$RETRY_V" ]; then + continue + fi + + # Tight retry produced no movement — either the constraint is + # outside composer.json's range (genuine widen-needed case), or + # the tight ~X.Y.Z range has no published version (e.g. + # WP never tagged 6.6.4, only 6.7+). For the second case we + # can still avoid widening composer.json by using a loose + # constraint `>=X.Y.Z, <(X+1).0.0` which lets composer pick + # the next safe version within the project's existing + # constraint. The first case will also try this and still + # fail — falling through to the widen step as before. + TIGHT=$(jq -r --arg p "$PACKAGE" '.[$p] // ""' /tmp/composer-update-constraints.json) + LOOSE=$(loosen_constraint "$TIGHT") + if [ -n "$LOOSE" ]; then + declare -a LOOSE_ARGS=("$PACKAGE:$LOOSE") + if ! grep -qFx "$PACKAGE" /tmp/composer-update-direct.txt; then + while IFS= read -r anc; do + [ -n "$anc" ] && LOOSE_ARGS+=("$anc") + done < <(find_direct_ancestors "$PACKAGE") + fi + + # Snapshot composer.json + composer.lock so we can revert if + # the loose retry moves the package to a still-vulnerable + # version. (composer's own audit-blocking usually prevents + # this, but we don't rely on it.) + cp composer.json /tmp/composer-update-pre-loose.json + cp composer.lock /tmp/composer-update-pre-loose.lock + + echo " retrying per-package (loose): composer update -W ${LOOSE_ARGS[*]}" + composer update -W "${LOOSE_ARGS[@]}" --no-interaction --no-scripts 2>&1 || true + LOOSE_V=$(get_lock_version "$PACKAGE" composer.lock) + if [ -n "$LOOSE_V" ] && [ "$BEFORE_V" != "$LOOSE_V" ]; then + if [ "$(is_still_vulnerable "$PACKAGE" "$LOOSE_V")" = "no" ]; then + echo " loose retry succeeded: $PACKAGE $BEFORE_V → $LOOSE_V (out of affected range, composer.json untouched)" + continue + else + echo "::warning::loose retry moved $PACKAGE to $LOOSE_V but it's still in the affected range — reverting" + cp /tmp/composer-update-pre-loose.json composer.json + cp /tmp/composer-update-pre-loose.lock composer.lock + fi + fi + fi + + UNHANDLED="$UNHANDLED $PACKAGE" +done + +if [ -z "$UNHANDLED" ]; then + echo "Updated all packages within existing constraints" + echo "changed=true" >> "$GITHUB_OUTPUT" +else + echo "Packages still needing a constraint bump:$UNHANDLED" + # Step 2: Constraints blocked the update — try composer require + # (bumps to latest, REPLACING the existing constraint). Same `-W` + # reasoning applies. + # + # Opt-out: packages listed under `extra.vuln-scan.no-widen` in the + # project's composer.json are skipped here. Use this for packages + # whose constraints are intentional (exact pin like "10.0.2", a + # locked-down range like "^1.1" the team won't widen automatically, + # license-tested version, etc.). The map's values are free-text + # reasons — they're not parsed by this action, they exist purely + # for in-place documentation of why each package opts out. + # + # Format (both shapes accepted): + # "extra": { "vuln-scan": { "no-widen": { + # "wpackagist-plugin/woocommerce": "License-tested only against 10.0.x" + # } } } + # or: + # "extra": { "vuln-scan": { "no-widen": [ + # "wpackagist-plugin/woocommerce" + # ] } } + NO_WIDEN=$(jq -r ' + .extra["vuln-scan"]["no-widen"] // {} | + if type == "array" then .[] else keys[] end + ' composer.json 2>/dev/null | tr '\n' ' ') + echo "No update within constraints, trying: composer require -W (filtered against extra.vuln-scan.no-widen)" + + # Expand the unhandled package list into the set of direct deps + # we'll actually widen. Packages already moved by the per-package + # update retry are NOT in $UNHANDLED, so they don't reach this + # step and won't have their constraint widened unnecessarily. + # Transitives get replaced by their direct-dep ancestor(s) via + # find_direct_ancestors (defined at the top of this step); + # multiple flagged packages converging on the same ancestor are + # de-duped so we only widen each ancestor once. + TARGETS="" + for PACKAGE in $UNHANDLED; do + if echo " $NO_WIDEN " | grep -qF " $PACKAGE "; then + echo " skip $PACKAGE — listed in extra.vuln-scan.no-widen" + continue + fi + + if grep -qFx "$PACKAGE" /tmp/composer-update-direct.txt; then + TARGETS="$TARGETS $PACKAGE" + continue + fi + + ancestors=$(find_direct_ancestors "$PACKAGE" | tr '\n' ' ') + if [ -z "${ancestors// /}" ]; then + echo "::warning::no direct-dep ancestor found for transitive $PACKAGE — cannot widen" + continue + fi + echo " $PACKAGE is transitive — will widen ancestor(s): $ancestors" + TARGETS="$TARGETS $ancestors" + done + + # Dedupe. `|| true`: empty TARGETS (all unhandled are no-widen) makes + # grep exit 1, which under `set -eo pipefail` would abort PR creation. + TARGETS=$(echo "$TARGETS" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' || true) + + for PACKAGE in $TARGETS; do + # Snapshot composer.json + composer.lock before each require so we + # can roll back if composer resolves the package to a dev-* branch + # constraint (e.g. dev-trunk). That happens on wpackagist plugins + # when the project sets minimum-stability: dev and no released tag + # outranks trunk — never a safe answer for a vuln scanner. + cp composer.json /tmp/composer-update-pre-require.json + cp composer.lock /tmp/composer-update-pre-require.lock + + # Preserve the require / require-dev placement. `composer require` + # defaults to `require`; without `--dev` it will MOVE a package + # that was originally in require-dev (e.g. phpunit/phpunit) into + # require, which silently changes the prod dependency surface. + REQUIRE_FLAGS="-W" + if jq -e --arg p "$PACKAGE" '(.["require-dev"] // {})[$p]' composer.json >/dev/null; then + REQUIRE_FLAGS="-W --dev" + fi + + BEFORE_REQUIRE_V=$(get_lock_version "$PACKAGE" /tmp/composer-update-pre-require.lock) + PKG_ARG=$(build_widen_arg "$PACKAGE") + + echo " widening: composer require $REQUIRE_FLAGS $PKG_ARG" + composer require $REQUIRE_FLAGS "$PKG_ARG" --no-interaction --no-scripts 2>&1 || true + + NEW_CONSTRAINT=$(jq -r --arg p "$PACKAGE" '(.require // {})[$p] // (.["require-dev"] // {})[$p] // ""' composer.json) + AFTER_REQUIRE_V=$(get_lock_version "$PACKAGE" composer.lock) + + # Two reasons to throw out the require outcome and restore the + # snapshot: + # + # 1. dev-* constraint — wpackagist plugins whose trunk readme + # declares a version higher than the latest stable tag can + # resolve to e.g. dev-trunk; never a safe answer for a vuln + # scanner. + # + # 2. Downgrade — `composer require -W ` with no constraint + # can pick a LOWER version than the one currently locked + # when audit.block-insecure blocks all sub-deps of newer + # versions (e.g. roots/wordpress 6.x metapackages all + # require roots/wordpress-no-content self.version, which is + # audit-blocked, so composer walks back to roots/wordpress + # 5.9.3 which predates the metapackage split). A fix-by- + # downgrade is never the right answer; surface it as a + # triage signal instead. + REVERT_REASON="" + case "$NEW_CONSTRAINT" in + dev-*) + REVERT_REASON="resolved to dev branch constraint '$NEW_CONSTRAINT'" + ;; + esac + if [ -z "$REVERT_REASON" ] && [ -n "$BEFORE_REQUIRE_V" ] && [ -n "$AFTER_REQUIRE_V" ] && [ "$BEFORE_REQUIRE_V" != "$AFTER_REQUIRE_V" ]; then + LOWER=$(printf '%s\n%s\n' "$BEFORE_REQUIRE_V" "$AFTER_REQUIRE_V" | sort -V | head -1) + if [ "$LOWER" = "$AFTER_REQUIRE_V" ]; then + REVERT_REASON="downgrade $BEFORE_REQUIRE_V → $AFTER_REQUIRE_V (likely blocked by audit advisory on a sub-dep)" + fi + fi + + if [ -n "$REVERT_REASON" ]; then + echo "::warning::composer require on $PACKAGE — reverting: $REVERT_REASON" + cp /tmp/composer-update-pre-require.json composer.json + cp /tmp/composer-update-pre-require.lock composer.lock + fi + done + + if git diff --quiet composer.json composer.lock; then + echo "No updates available" + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Updated with constraint bump" + echo "changed=true" >> "$GITHUB_OUTPUT" +fi + +# Build a version-diff table from the before tempfile (saved above) +# vs the post-update composer.lock. +# from_entries requires {key, value} — it silently drops other field +# names like `version`, producing a map of {pkg: null} regardless of +# actual version. Map to {key, value} explicitly. +JQ_VERSIONS='[(.packages + ."packages-dev")[] | {key: .name, value: .version}] | from_entries' +jq "$JQ_VERSIONS" /tmp/composer-update-before.lock > /tmp/composer-update-before.json +jq "$JQ_VERSIONS" composer.lock > /tmp/composer-update-after.json + +echo "Before snapshot: $(jq 'length' /tmp/composer-update-before.json) packages" +echo "After snapshot: $(jq 'length' /tmp/composer-update-after.json) packages" + +# Count packages with actual version changes (or added/removed). +# If zero, the composer.lock diff is metadata-only (refreshed dist +# references, content-hash) and doesn't actually fix any vulnerability +# — skip PR creation rather than opening noise. +VERSION_DIFF_COUNT=$(jq -r --slurpfile after /tmp/composer-update-after.json ' + ([to_entries[] + | ($after[0][.key] // null) as $n + | select($n != null and $n != .value) + ] | length) + + ([$after[0] | keys[]] - [keys[]] | length) + + ([keys[]] - [$after[0] | keys[]] | length) +' /tmp/composer-update-before.json) + +echo "Version changes detected: $VERSION_DIFF_COUNT" + +if [ "$VERSION_DIFF_COUNT" -eq 0 ]; then + echo "composer.lock changed but no package versions moved — skipping PR" + echo "changed=false" >> "$GITHUB_OUTPUT" + # Reset lock to avoid leaving metadata-only changes staged + git checkout -- composer.json composer.lock 2>/dev/null || true + exit 0 +fi + +{ + echo "" + echo "## Version changes" + echo "" + echo "| Package | From | To |" + echo "|---|---|---|" + jq -r --slurpfile after /tmp/composer-update-after.json ' + to_entries[] as $b + | ($after[0][$b.key] // null) as $n + | select($n != null and $n != $b.value) + | "| `\($b.key)` | `\($b.value)` | `\($n)` |" + ' /tmp/composer-update-before.json | sort + # New packages (present in after but not before) + jq -r --slurpfile after /tmp/composer-update-after.json ' + [$after[0] | keys[]] - [keys[]] | .[] + ' /tmp/composer-update-before.json | while read -r pkg; do + [ -z "$pkg" ] && continue + v=$(jq -r --arg k "$pkg" '.[$k]' /tmp/composer-update-after.json) + echo "| \`$pkg\` | _(new)_ | \`$v\` |" + done + # Removed packages (present in before but not after) + jq -r --slurpfile after /tmp/composer-update-after.json ' + [keys[]] - [$after[0] | keys[]] | .[] + ' /tmp/composer-update-before.json | while read -r pkg; do + [ -z "$pkg" ] && continue + v=$(jq -r --arg k "$pkg" '.[$k]' /tmp/composer-update-before.json) + echo "| \`$pkg\` | \`$v\` | _(removed)_ |" + done +} > /tmp/composer-update-versions.md + +echo "Version changes table:" +cat /tmp/composer-update-versions.md + diff --git a/composer-update/tests/no-widen-still-creates-pr.test.sh b/composer-update/tests/no-widen-still-creates-pr.test.sh new file mode 100755 index 0000000..8ef6e0e --- /dev/null +++ b/composer-update/tests/no-widen-still-creates-pr.test.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# +# Regression test for composer-update/scripts/update.sh +# +# Bug: when EVERY constraint-blocked package is listed in +# extra.vuln-scan.no-widen, the widen-targets dedupe ran `grep -v '^$'` over +# empty input, which exits 1 and (under `set -eo pipefail`) aborted the whole +# step — dropping the PR even though OTHER packages had updated cleanly. +# +# This test drives the real script with a fake `composer` and asserts that a +# safe package (symfony/yaml) still gets updated and changed=true is emitted, +# while a no-widen package (woocommerce) is skipped without aborting. +# +# No network, no real composer. Requires: bash, git, jq. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ACTION_PATH="$SCRIPT_DIR" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cd "$TMP" + +git init -q +git config user.email test@example.com +git config user.name test + +cat > composer.json <<'JSON' +{ + "require": { + "symfony/yaml": "^7.0", + "wpackagist-plugin/woocommerce": "10.0.2" + }, + "extra": { + "vuln-scan": { + "no-widen": { + "wpackagist-plugin/woocommerce": "Pinned — integration-tested against this exact version." + } + } + } +} +JSON + +cat > composer.lock <<'JSON' +{ + "packages": [ + { "name": "symfony/yaml", "version": "v7.3.3", "require": {} }, + { "name": "wpackagist-plugin/woocommerce", "version": "10.0.2", "require": {} } + ], + "packages-dev": [] +} +JSON + +git add -A +git commit -qm init + +# Fake composer: only a `composer update -W ... symfony/yaml ...` call moves +# symfony/yaml in the lock. woocommerce never moves within constraints (its fix +# is outside the pin) — exactly the scenario that triggered the bug. `require` +# (widen) is a no-op; it must not be reached for the no-widen package anyway. +mkdir bin +cat > bin/composer <<'SH' +#!/usr/bin/env bash +args="$*" +case "$args" in + update*symfony/yaml*) + tmp=$(mktemp) + jq '(.packages[] | select(.name=="symfony/yaml") | .version) |= "v7.4.12"' composer.lock > "$tmp" && mv "$tmp" composer.lock + ;; +esac +exit 0 +SH +chmod +x bin/composer +export PATH="$TMP/bin:$PATH" + +export PACKAGES="symfony/yaml wpackagist-plugin/woocommerce" +export VULNS_JSON="" +export GITHUB_ACTION_PATH="$ACTION_PATH" +export GITHUB_OUTPUT="$TMP/github_output" +: > "$GITHUB_OUTPUT" + +# Run the real script. Before the fix this exits non-zero here. +bash "$ACTION_PATH/scripts/update.sh" > "$TMP/log" 2>&1 || { + echo "FAIL: update.sh exited non-zero (the no-widen abort bug)" + cat "$TMP/log" + exit 1 +} + +fail() { echo "FAIL: $1"; echo "--- output ---"; cat "$TMP/github_output"; echo "--- log ---"; cat "$TMP/log"; exit 1; } + +grep -q '^changed=true' "$GITHUB_OUTPUT" || fail "expected changed=true in GITHUB_OUTPUT" +jq -e '.packages[] | select(.name=="symfony/yaml" and .version=="v7.4.12")' composer.lock >/dev/null \ + || fail "expected symfony/yaml bumped to v7.4.12 in composer.lock" +jq -e '.packages[] | select(.name=="wpackagist-plugin/woocommerce" and .version=="10.0.2")' composer.lock >/dev/null \ + || fail "expected woocommerce left at its pinned 10.0.2" +grep -q "skip wpackagist-plugin/woocommerce — listed in extra.vuln-scan.no-widen" "$TMP/log" \ + || fail "expected woocommerce to be skipped via no-widen" + +echo "PASS: safe package updated, no-widen package skipped, PR would be created"