Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ Each release moves `plugin.json`'s `version`, the flake package version and ever
together. A consumer adopts one by bumping its flake input, its workflow `@v…`/`devkit-ref` and its
marketplace `ref` at once.

## Unreleased

- `deps-merge --repo OWNER/REPO --unattended` merges only a green Dependabot PR that changes Python
manifests alone, every version by a patch or minor (read from the diff), with no `hold` label and no
commit by anyone else, pinned to the head commit its checks ran on. The interactive mode is unchanged
(marola-dev/marola#740, #150).

## v0.17.0 — 2026-10-17

- **Breaking** for a caller that passes `token:` to `skills-update`: the secret is no longer declared,
Expand Down
2 changes: 1 addition & 1 deletion docs/4-reference_tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ making them. A tool that calls `gh` needs a login on the host; inside ai-jail it
| `mip-stack` | `mip-stack.sh` | `just mip-stack` | Chain every open MIP draft PR into one GitHub Stack, resolving the shared `docs/MIPs/README.md` row conflict; `--resume`, `--skip <PR#>`, `status`, `clean` |
| `docs-mip-stack` | `docs-mip-stack.sh` | `just docs-mip-stack` | Chain un-merged `docs/mip-NNNN-*` branches: `auto` (push and plan the unambiguous local ones), `list`, `plan <branch>…` (conflict check with `git merge-tree`, prints the `gh pr create` lines) |
| `deps-stack` | `deps-stack.sh` | `just deps-stack` | Chain open dependency-update PRs into one GitHub Stack, auto-resolving bump-vs-bump conflicts; `--resume`, `--skip`, `--include-steward`, `status`, `clean`. Built in `.tmp/wt-deps-stack`, never the caller's checkout |
| `deps-merge` | `deps-merge.sh` | `just deps-merge` | Merge every open dependency PR that is mergeable with every check green (skipped and neutral count as green); reports the rest |
| `deps-merge` | `deps-merge.sh` | `just deps-merge` | Merge every open dependency PR that is mergeable with every check green (skipped and neutral count as green); reports the rest. `--repo OWNER/REPO --unattended`, for a scheduled job, also needs Dependabot as the author of the PR and every commit, no `hold` label, only Python manifests changed (`requirements*.txt`, `pyproject.toml`, a lock file), and each changed line one dependency whose versions moved by a patch or minor (a 0.x minor counts as major), read from the diff; it pins the merge to the checked head commit and writes one stderr line per refused PR. The caller checks `AUTO_MERGE` |
| `bump-merge` | `bump-merge.sh` | `just bump-merge` | Squash-merge the newest release's `chore/devkit-vX.Y.Z` PRs in every `consumers.txt` repo the umbrella first, with an optional soak and canary delay (both off by default); `--dry-run` prints each decision and command, `--now EPOCH` sets the clock. Run hourly and on `bump-ready` by `bump-merge.yml`; the rules are in [workflows](4-reference_workflows.md#bump-merge-not-reusable) |
| `branches` | `branches.sh` | `just branches-clean`, `just branches-open` | `clean` deletes local branches whose PR merged (local and remote ref); `open` opens a `main`-based PR for each plain branch ahead of `main` with none |

Expand Down
138 changes: 126 additions & 12 deletions scripts/deps-merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
# just deps-merge # merge what is ready, report what is not and why
# just deps-merge --dry-run # print the gh commands, mutate nothing
# just deps-merge --from-json f # judge this file instead of calling gh (testing)
# just deps-merge --self-test # scripts/fixtures/deps-merge-prs.json, assert the verdicts
# just deps-merge --self-test # scripts/fixtures/deps-merge-*.json, assert the verdicts
# deps-merge --repo OWNER/REPO --unattended # the scheduled job's mode, stricter rules below
#
# The counterpart to deps-stack.sh: that one chains open dependency PRs into a stack when they
# arrive separately, this one closes them out. A PR is merged only when GitHub says MERGEABLE and
Expand All @@ -14,27 +15,43 @@
#
# Order is deps-stack's: github-actions first, then pip, each by PR number ascending, so a run
# that merges several leaves the smallest rebase for whatever is left.
#
# --unattended (marola-dev/marola#740, MIP-0086 §5.5) also requires: Dependabot as author of the PR
# and of every commit, no `hold` label, only Python manifests changed, and every changed manifest
# line the same dependency with only its versions moved, none by a major (on 0.x, a minor counts as
# major). Versions come from the diff, not the PR text: Dependabot's pip range updates carry no
# update-type. The merge pins the head commit the checks ran on. The caller checks AUTO_MERGE.
set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
dry=0
from_json=""
self_test=0
unattended=false
repo=""

while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry=1 ;;
--from-json) from_json="${2:?--from-json needs a file}"; shift ;;
--self-test) self_test=1 ;;
-h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
--unattended) unattended=true ;;
--repo) repo="${2:?--repo needs OWNER/REPO}"; shift ;;
-h|--help) sed -n '2,23p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "deps-merge: unknown argument: $1" >&2; exit 2 ;;
esac
shift
done
if [ "$unattended" = true ] && [ -z "$repo" ] && [ "$self_test" -eq 0 ]; then
echo "deps-merge: --unattended needs --repo OWNER/REPO" >&2; exit 2
fi
repo_args=()
[ -z "$repo" ] || repo_args=(-R "$repo")

# stdin: gh-pr-list-shaped JSON -> stdout: same, ordered, each with .ecosystem/.ready/.reason
# $1 true|false overrides --unattended. stdin: gh-pr-list-shaped JSON -> stdout: same, ordered,
# each with .ecosystem/.ready/.reason
judge() {
jq '
jq --argjson unattended "${1:-$unattended}" '
def eco:
if (.headRefName | test("^dependabot/github_actions/")) then "github-actions"
elif (.headRefName | test("^dependabot/pip/")) then "pip"
Expand All @@ -56,7 +73,51 @@ judge() {
elif running > 0 then "checks still running"
elif (red | length) > 0 then "checks not green (\(red | unique | join(", ")))"
else "" end;
map(. + {ecosystem: eco, rank: rank, reason: verdict})
def manifest: test("(^|/)(requirements[^/]*\\.txt|pyproject\\.toml)$");
def lockfile: test("(^|/)(uv|poetry|pdm|Pipfile)\\.lock$");
def ver_re: "[0-9]+(?:\\.[0-9]+)*";
# A requirements line or a quoted pyproject entry; `version = "1.2"` has no operator, so is not one.
def dep_line:
.[0:1] as $sign | .[1:] | sub("^\\s+"; "") | sub("^[\"\u0027]"; "") | sub("[\"\u0027],?\\s*$"; "")
| ([capture("^(?<name>[A-Za-z0-9][A-Za-z0-9._-]*)(?<rest>\\s*(\\[[^]]*\\])?\\s*(===|==|>=|<=|~=|!=|<|>).*)$")] | first)
| if . == null then null
else {sign: $sign, name: (.name | ascii_downcase | gsub("[-_.]+"; "-")), rest: .rest} end;
def parts: split(".") | map(tonumber);
def is_major($o; $n): ($o | parts) as $a | ($n | parts) as $b
| $a[0] != $b[0] or ($a[0] == 0 and ($a[1] // 0) != ($b[1] // 0));
def pair_verdict:
(map(select(.sign == "-"))[0].rest) as $o | (map(select(.sign == "+"))[0].rest) as $n
| ($o | [scan(ver_re)]) as $ov | ($n | [scan(ver_re)]) as $nv
| if ($o | gsub(ver_re; "V")) != ($n | gsub(ver_re; "V")) then "changes more than a version (\(.[0].name))"
elif any(range(0; $ov | length); is_major($ov[.]; $nv[.])) then
"a major update (\(.[0].name): \($o | ltrimstr(" ")) -> \($n | ltrimstr(" ")))"
else "" end;
def versions_verdict:
[.files[] | select(.path | manifest) | .patch | split("\n")[] | select(test("^[-+]")) | dep_line] as $ls
| ($ls | map(select(. != null)) | group_by(.name)) as $groups
| if any($ls[]; . == null) then "changes a manifest line that is not a dependency"
elif ($groups | length) == 0 then "no dependency version change to judge"
else ([$groups[] | select((map(.sign) | sort) != ["+", "-"]) | .[0].name] | first) as $odd
| if $odd != null then "adds or removes a dependency (\($odd))"
else ([$groups[] | pair_verdict | select(. != "")] | first) // "" end
end;
def unattended_verdict:
([.files[]?.path | select((manifest or lockfile) | not)] | first) as $stray
| ([.files[]? | select((.path | manifest) and .patch == null) | .path] | first) as $nodiff
| if (.author.login // "") as $a | ["app/dependabot", "dependabot[bot]"] | index($a) | not
then "author is not Dependabot (\(.author.login // "unknown"))"
elif any(.labels[]?; .name == "hold") then "labelled hold"
elif (.commits // []) | length == 0 then "no commits reported"
elif any(.commits[].authors[]?; .login != "dependabot[bot]") then "has a commit not by Dependabot"
elif (.files // []) | length == 0 then "no changed files reported"
elif $stray != null then "changes a file outside the Python manifests (\($stray))"
elif $nodiff != null then "no diff for \($nodiff)"
elif any(.commits[]; (.messageBody // "") | test("update-type: version-update:semver-major"))
then "Dependabot marks a major update"
else versions_verdict end;
map(. + {ecosystem: eco, rank: rank,
reason: (if $unattended then (verdict as $v | unattended_verdict | if . == "" then $v else . end)
else verdict end)})
| map(. + {ready: (.reason == "")})
| sort_by([.rank, .number])
'
Expand All @@ -68,8 +129,20 @@ discover() {
return 0
fi
gh auth status >/dev/null 2>&1 || { echo "gh is not logged in — run: gh auth login" >&2; exit 1; }
gh pr list --state open --search "author:app/dependabot" \
--json number,title,headRefName,mergeable,statusCheckRollup
if [ "$unattended" = false ]; then
gh pr list "${repo_args[@]}" --state open --search "author:app/dependabot" \
--json number,title,headRefName,mergeable,statusCheckRollup
return 0
fi
local pr n files
gh pr list -R "$repo" --state open --search "author:app/dependabot" \
--json number,title,headRefName,headRefOid,mergeable,statusCheckRollup,author,labels,commits \
| jq -c '.[]' | while read -r pr; do
n="$(jq -r .number <<<"$pr")"
# gh pr list has the paths but not the patches the version rule reads.
files="$(gh api --paginate "repos/$repo/pulls/$n/files" -q '.[] | {path: .filename, patch}' | jq -s .)"
jq --argjson files "$files" '. + {files: $files}' <<<"$pr"
done | jq -s .
}

self_test() {
Expand Down Expand Up @@ -100,6 +173,40 @@ self_test() {
got="$(jq -r '.[] | select(.number == 302) | .ready' <<<"$judged")"
check "SKIPPED jobs count as green (ci.yml's path filters skip most)" "$got" "true"

fixture="$script_dir/fixtures/deps-merge-unattended.json"
got="$(judge <"$fixture" | jq -c '[.[] | select(.ready) | .number]')"
check "interactive mode ignores the unattended rules" "$got" "[401,402,403,404,407,408,409,410,411,412,413,414]"
judged="$(judge true <"$fixture")"
got="$(jq -c '[.[] | select(.ready) | .number]' <<<"$judged")"
check "unattended: only green patch/minor manifest-only Dependabot PRs merge" "$got" "[401,412]"
while IFS='|' read -r n want; do
got="$(jq -r --argjson n "$n" '.[] | select(.number == $n) | .reason' <<<"$judged")"
check "unattended refuses #$n" "$got" "$want"
done <<'EOF'
402|a major update (dspy: >=3.4.1,<4 -> >=4.0.0,<5)
403|author is not Dependabot (app/marola-pr-bot)
404|changes a file outside the Python manifests (.github/workflows/compile-prompt.yml)
405|checks not green (FAILURE)
406|checks still running
407|labelled hold
408|has a commit not by Dependabot
409|adds or removes a dependency (evil-pkg)
410|a major update (peft: >=0.21.2 -> >=0.22.0)
411|Dependabot marks a major update
413|a major update (mlflow: >=3.17.1,<4 -> >=3.17.1,<5)
414|changes a manifest line that is not a dependency
EOF
local out err
err="$(mktemp)"
out="$(bash "${BASH_SOURCE[0]}" --unattended --repo o/r --from-json "$fixture" --dry-run 2>"$err")"
got="$(grep -c '^+ gh pr merge' <<<"$out")"
check "unattended dry run merges two" "$got" "2"
got="$(grep -c -- '+ gh pr merge 401 -R o/r --squash --delete-branch --match-head-commit 401aaaa$' <<<"$out")"
check "the merge pins the head commit the checks ran on" "$got" "1"
got="$(grep -c '^deps-merge: #[0-9]* refused: ' "$err")"
check "one stderr line per refused PR" "$got" "12"
rm -f "$err"

echo "deps-merge self-test:" "$([ "$fails" -eq 0 ] && echo ok || echo "$fails FAILED")"
[ "$fails" -eq 0 ]
}
Expand All @@ -112,24 +219,31 @@ if [ "$(jq 'length' <<<"$judged")" -eq 0 ]; then
exit 0
fi

jq -r '.[] | " #\(.number) \(.ecosystem) \(if .ready then "READY" else "skip — \(.reason)" end)\n \(.title)"' <<<"$judged"
if [ "$unattended" = true ]; then
jq -r '.[] | select(.ready) | " #\(.number) READY \(.title)"' <<<"$judged"
jq -r '.[] | select(.ready | not) | "deps-merge: #\(.number) refused: \(.reason)"' <<<"$judged" >&2
else
jq -r '.[] | " #\(.number) \(.ecosystem) \(if .ready then "READY" else "skip — \(.reason)" end)\n \(.title)"' <<<"$judged"
fi

merged=0
failed=0
while read -r n; do
while read -r n oid; do
[ -n "$n" ] || continue
cmd=(gh pr merge "$n" "${repo_args[@]}" --squash --delete-branch)
[ "$unattended" = false ] || cmd+=(--match-head-commit "$oid")
if [ "$dry" -eq 1 ]; then
echo "+ gh pr merge $n --squash --delete-branch"
echo "+ ${cmd[*]}"
continue
fi
echo "merging #$n ..."
if gh pr merge "$n" --squash --delete-branch; then
if "${cmd[@]}"; then
merged=$((merged + 1))
else
failed=$((failed + 1))
echo "deps-merge: #$n did not merge — left open" >&2
fi
done < <(jq -r '.[] | select(.ready) | .number' <<<"$judged")
done < <(jq -r '.[] | select(.ready) | "\(.number) \(.headRefOid // "")"' <<<"$judged")

[ "$dry" -eq 1 ] && { echo "dry run: nothing was merged"; exit 0; }
echo "deps-merge: merged $merged, left $(jq '[.[] | select(.ready | not)] | length' <<<"$judged") not ready, $failed failed"
Expand Down
Loading
Loading