diff --git a/composer-update/action.yml b/composer-update/action.yml index efaade8..21fa95b 100644 --- a/composer-update/action.yml +++ b/composer-update/action.yml @@ -42,64 +42,54 @@ inputs: outputs: pr_url: - description: 'URL of the open PR — newly created, or the existing one hit by dedup. Empty if no PR was opened or touched.' + description: 'URL of the open PR — newly created, or the existing one refreshed/deduped. Empty if no PR was opened or touched.' value: ${{ steps.create.outputs.pr_url || steps.check.outputs.pr_url }} runs: using: 'composite' steps: - - name: Check for existing PR (dedup) + - name: Resolve existing PR (dedup) id: check shell: bash env: GH_TOKEN: ${{ inputs.token || github.token }} PR_BODY: ${{ inputs.pr_body }} run: | - # Dedup by label. If an open PR with this label exists we skip creating - # a duplicate. To avoid silently dropping NEW findings while a stale PR - # sits open, we compare the marker in PR_BODY (e.g. ``) to the same marker in the open PR's body — if the package - # set differs, post a comment so reviewers see the new findings. - EXISTING=$(gh pr list --label "${{ inputs.pr_label }}" --state open --limit 1 --json number,url,body) - EXISTING_NUM=$(echo "$EXISTING" | jq -r '.[0].number // empty') - - if [ -z "$EXISTING_NUM" ]; then - echo "No open PR with label '${{ inputs.pr_label }}' — will create one" - echo "skip=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Expose the existing PR URL so callers can link to it even on silent dedup. - EXISTING_URL=$(echo "$EXISTING" | jq -r '.[0].url // empty') - echo "pr_url=$EXISTING_URL" >> "$GITHUB_OUTPUT" - - echo "Open PR #$EXISTING_NUM with label '${{ inputs.pr_label }}' exists — checking if findings differ" - EXISTING_BODY=$(echo "$EXISTING" | jq -r '.[0].body // ""') - - # Extract `` marker from both bodies. - # If markers match (or both empty), the findings are identical — skip silently. + # One rolling PR per label. Decide create / skip / update by comparing + # the `` marker in PR_BODY against the same + # marker in the open PR's body: + # create — no open PR → "Open or refresh PR" makes one + # skip — open PR has this set → do nothing (findings unchanged) + # update — open PR, set changed → refresh that PR in place + # The refresh rewrites the PR body (so the marker tracks the latest + # findings); that's what makes an unchanged set hit `skip` the next run + # instead of re-commenting daily. Decision logic lives in lib.sh so it + # is unit-tested (see tests/lib-functions.test.sh). + source "$GITHUB_ACTION_PATH/scripts/lib.sh" MARKER_KEY="${{ inputs.pr_label }}-set" - EXISTING_SET=$(printf '%s' "$EXISTING_BODY" | grep -oE "$MARKER_KEY: [^ ]+ -->" | head -1 | sed -E "s/.*${MARKER_KEY}: ([^ ]+) -->.*/\1/") - NEW_SET=$(printf '%s' "$PR_BODY" | grep -oE "$MARKER_KEY: [^ ]+ -->" | head -1 | sed -E "s/.*${MARKER_KEY}: ([^ ]+) -->.*/\1/") + NEW_SET=$(extract_pr_set "$PR_BODY" "$MARKER_KEY") - echo "Existing set: ${EXISTING_SET:-}" - echo "New set: ${NEW_SET:-}" + EXISTING=$(gh pr list --label "${{ inputs.pr_label }}" --state open --limit 1 --json number,url,headRefName,body) + EXISTING_NUM=$(echo "$EXISTING" | jq -r '.[0].number // empty') - if [ -n "$NEW_SET" ] && [ "$NEW_SET" = "$EXISTING_SET" ]; then - echo "Findings unchanged since PR #$EXISTING_NUM was opened — skipping silently" + if [ -z "$EXISTING_NUM" ]; then + MODE=$(decide_pr_action false "$NEW_SET" "") + echo "No open PR with label '${{ inputs.pr_label }}' — mode=$MODE" else - echo "Findings differ — posting comment to PR #$EXISTING_NUM" - RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - { - printf '## Updated findings — [run #%s](%s)\n\n' "${{ github.run_number }}" "$RUN_URL" - printf '%s\n' "$PR_BODY" - } > /tmp/composer-update-comment.md - gh pr comment "$EXISTING_NUM" --body-file /tmp/composer-update-comment.md + EXISTING_URL=$(echo "$EXISTING" | jq -r '.[0].url // empty') + EXISTING_HEAD=$(echo "$EXISTING" | jq -r '.[0].headRefName // empty') + EXISTING_BODY=$(echo "$EXISTING" | jq -r '.[0].body // ""') + EXISTING_SET=$(extract_pr_set "$EXISTING_BODY" "$MARKER_KEY") + MODE=$(decide_pr_action true "$NEW_SET" "$EXISTING_SET") + echo "pr_url=$EXISTING_URL" >> "$GITHUB_OUTPUT" + echo "existing_num=$EXISTING_NUM" >> "$GITHUB_OUTPUT" + echo "existing_head=$EXISTING_HEAD" >> "$GITHUB_OUTPUT" + echo "Open PR #$EXISTING_NUM | existing set: ${EXISTING_SET:-} | new set: ${NEW_SET:-} | mode=$MODE" fi - echo "skip=true" >> "$GITHUB_OUTPUT" + echo "mode=$MODE" >> "$GITHUB_OUTPUT" - name: Update packages - if: steps.check.outputs.skip != 'true' + if: steps.check.outputs.mode != 'skip' id: update shell: bash env: @@ -107,19 +97,39 @@ runs: 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' + - name: Open or refresh PR + if: steps.check.outputs.mode != 'skip' id: create shell: bash env: GH_TOKEN: ${{ inputs.token || github.token }} PR_BODY: ${{ inputs.pr_body }} PACKAGES: ${{ inputs.packages }} + MODE: ${{ steps.check.outputs.mode }} + CHANGED: ${{ steps.update.outputs.changed }} + EXISTING_NUM: ${{ steps.check.outputs.existing_num }} + EXISTING_HEAD: ${{ steps.check.outputs.existing_head }} + EXISTING_URL: ${{ steps.check.outputs.pr_url }} run: | - BRANCH="${{ inputs.branch_prefix }}-$(date +%Y%m%d-%H%M%S)" + # Full PR body = the scan-provided body (marker + findings table) plus + # the Version changes table the update step produced (if any). Body-file + # keeps backticks / $() in the markdown literal. + { + printf '%s' "$PR_BODY" + if [ -s /tmp/composer-update-versions.md ]; then + cat /tmp/composer-update-versions.md + fi + } > /tmp/composer-update-pr-body.md + + # create + no resolvable fix → there's nothing to open and no PR to + # refresh. (The unfixable finding still surfaces via the caller's alert.) + if [ "$MODE" = "create" ] && [ "$CHANGED" != "true" ]; then + echo "No update available and no existing PR — nothing to open" + exit 0 + fi + git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - # Remove actions/checkout's credential config — it sets includeIf # entries and extraheader with the GITHUB_TOKEN which overrides any # other auth method we try to use. Must be cleared before pushing. @@ -129,40 +139,63 @@ runs: git config --local --unset-all 'http.https://github.com/.extraheader' 2>/dev/null || true git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" - git checkout -b "$BRANCH" - git add composer.json composer.lock - - # Commit message: title in subject, package list in body so `git blame` - # on composer.lock answers "why did this version change?". - { - printf '%s\n\n' "${{ inputs.pr_title }}" - echo "Updated packages:" - for pkg in $PACKAGES; do - echo "- $pkg" - done - } > /tmp/composer-update-commit.txt - git commit -F /tmp/composer-update-commit.txt - - git push origin "$BRANCH" + # Commit the working-tree fix. Title in subject, package list in body so + # `git blame` on composer.lock answers "why did this version change?". + commit_fix() { + git add composer.json composer.lock + { + printf '%s\n\n' "${{ inputs.pr_title }}" + echo "Updated packages:" + for pkg in $PACKAGES; do + echo "- $pkg" + done + } > /tmp/composer-update-commit.txt + git commit -F /tmp/composer-update-commit.txt + } + + if [ "$MODE" = "create" ]; then + BRANCH="${{ inputs.branch_prefix }}-$(date +%Y%m%d-%H%M%S)" + git checkout -b "$BRANCH" + commit_fix + git push origin "$BRANCH" + # Ensure the dedup label exists (idempotent — no-ops if present). + gh label create "${{ inputs.pr_label }}" \ + --color FFAA00 \ + --description "Auto-generated dependency update PR" \ + 2>/dev/null || true + PR_URL=$(gh pr create \ + --title "${{ inputs.pr_title }}" \ + --label "${{ inputs.pr_label }}" \ + --body-file /tmp/composer-update-pr-body.md) + echo "Created PR: $PR_URL" + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + exit 0 + fi - # Ensure the dedup label exists on the repo (idempotent — silently - # no-ops if it already exists). Avoids needing per-repo setup. - gh label create "${{ inputs.pr_label }}" \ - --color FFAA00 \ - --description "Auto-generated dependency update PR" \ - 2>/dev/null || true + # MODE=update: refresh the existing PR in place so it always reflects + # the current findings, rather than opening a second PR or letting the + # original go stale. + if [ "$CHANGED" = "true" ]; then + # Force the PR's head branch to "base + current fix". We're on the + # freshly-updated checkout, so commit here and force-push to its head. + git checkout -b "refresh-$(date +%Y%m%d-%H%M%S)" + commit_fix + git push -f origin "HEAD:${EXISTING_HEAD}" + echo "Refreshed branch '$EXISTING_HEAD' on PR #$EXISTING_NUM" + else + echo "New findings have no resolvable fix — refreshing PR #$EXISTING_NUM body/comment only" + fi - # Use --body-file so backticks / $() / etc. in markdown stay literal. - # Append the Version changes table built by the Update step (if any). + # Rewrite the body so the set marker tracks the latest findings (this is + # what lets an unchanged set hit `skip` next run instead of re-posting), + # and drop a timeline comment so reviewers see the change. Because this + # only runs in `update` mode (set genuinely changed), it can't recur + # daily on a static finding set. + gh pr edit "$EXISTING_NUM" --body-file /tmp/composer-update-pr-body.md + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" { - printf '%s' "$PR_BODY" - if [ -s /tmp/composer-update-versions.md ]; then - cat /tmp/composer-update-versions.md - fi - } > /tmp/composer-update-pr-body.md - PR_URL=$(gh pr create \ - --title "${{ inputs.pr_title }}" \ - --label "${{ inputs.pr_label }}" \ - --body-file /tmp/composer-update-pr-body.md) - echo "Created PR: $PR_URL" - echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + printf '## Findings changed — refreshed by [run #%s](%s)\n\n' "${{ github.run_number }}" "$RUN_URL" + printf '%s\n' "$PR_BODY" + } > /tmp/composer-update-comment.md + gh pr comment "$EXISTING_NUM" --body-file /tmp/composer-update-comment.md + echo "pr_url=$EXISTING_URL" >> "$GITHUB_OUTPUT" diff --git a/composer-update/scripts/lib.sh b/composer-update/scripts/lib.sh index 2b15dfb..4f6a71e 100755 --- a/composer-update/scripts/lib.sh +++ b/composer-update/scripts/lib.sh @@ -162,3 +162,37 @@ get_lock_version() { | map(select(.name == $p)) | first | .version // empty ' "$2" } + +# --- PR dedup / refresh decision (used by action.yml's "Resolve existing PR")--- + +# Extract a `` set marker from a PR body. Echoes the +# comma-joined set (e.g. "a,b") or empty string if the marker is absent. The +# scan workflow embeds the sorted-unique finding set in this marker so a PR can +# be compared against the current findings across runs. +extract_pr_set() { + local body="$1" marker_key="$2" + printf '%s' "$body" | grep -oE "$marker_key: [^ ]+ -->" | head -1 \ + | sed -E "s/.*${marker_key}: ([^ ]+) -->.*/\1/" +} + +# Decide what to do with an auto-update PR given the current findings: +# create — no open PR exists → open a fresh one +# skip — an open PR already records this exact set → do nothing (silent) +# update — an open PR exists but the set changed → refresh it in place +# Args: +# +# `update` (not a fresh PR) keeps a single rolling PR per label, and refreshing +# the existing PR's body marker is what makes a subsequently-unchanged set hit +# `skip` next run — so findings drift no longer re-comments every day. An empty +# new_set (no marker) is treated as a change, matching the pre-extraction +# behavior of always notifying when the marker can't be compared. +decide_pr_action() { + local has_existing="$1" new_set="$2" existing_set="$3" + if [ "$has_existing" != "true" ]; then + echo create + elif [ -n "$new_set" ] && [ "$new_set" = "$existing_set" ]; then + echo skip + else + echo update + fi +} diff --git a/composer-update/tests/lib-functions.test.sh b/composer-update/tests/lib-functions.test.sh index b28f124..be92071 100755 --- a/composer-update/tests/lib-functions.test.sh +++ b/composer-update/tests/lib-functions.test.sh @@ -67,6 +67,21 @@ assert_contains "$out" "roots/wordpress" "plus the direct-dep ancestor" echo '{"roots/wordpress":"~6.8.5"}' > "$CON" assert_eq "$(expand_args_for roots/wordpress)" "roots/wordpress:~6.8.5" "direct dep -> only its own arg" +echo "== extract_pr_set ==" +body_a='blah + +## title' +assert_eq "$(extract_pr_set "$body_a" vuln-update-set)" "a/x,b/y" "extracts the marker set" +assert_eq "$(extract_pr_set "no marker here" vuln-update-set)" "" "absent marker -> empty" +assert_eq "$(extract_pr_set "$body_a" dependencies-set)" "" "different label key -> empty" + +echo "== decide_pr_action ==" +assert_eq "$(decide_pr_action false 'a,b' '')" "create" "no existing PR -> create" +assert_eq "$(decide_pr_action true 'a,b' 'a,b')" "skip" "same set -> skip (silent)" +assert_eq "$(decide_pr_action true 'a,b,c' 'a,b')" "update" "set grew -> update in place" +assert_eq "$(decide_pr_action true 'a' 'a,b')" "update" "set shrank -> update in place" +assert_eq "$(decide_pr_action true '' 'a,b')" "update" "empty new set (no marker) -> update, never silent" + echo "== get_lock_version ==" printf '{"packages":[{"name":"vendor/a","version":"v1.2.3"}],"packages-dev":[{"name":"dev/b","version":"2.0.0"}]}' > /tmp/_lock.json assert_eq "$(get_lock_version vendor/a /tmp/_lock.json)" "v1.2.3" "reads version from packages"