Skip to content

Commit ce17651

Browse files
tclemCopilot
andcommitted
Automate dependency update PRs
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 41d51ab commit ce17651

7 files changed

Lines changed: 648 additions & 263 deletions

File tree

.github/dependabot.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ version: 2
33
updates:
44
- package-ecosystem: "cargo"
55
directory: "/"
6+
# Version PRs are consolidated by update-dependencies.yaml. Security updates remain enabled.
7+
open-pull-requests-limit: 0
68
schedule:
79
interval: "cron"
810
cronjob: "0 5 2 * *" # Second day of each month at 05:00 UTC
@@ -11,6 +13,8 @@ updates:
1113

1214
- package-ecosystem: "github-actions"
1315
directory: "/"
16+
# Version PRs are consolidated by update-dependencies.yaml. Security updates remain enabled.
17+
open-pull-requests-limit: 0
1418
schedule:
1519
interval: "cron"
1620
cronjob: "0 5 2 * *" # Second day of each month at 05:00 UTC
@@ -19,6 +23,8 @@ updates:
1923

2024
- package-ecosystem: "npm"
2125
directory: "/crates/string-offsets/js"
26+
# Version PRs are consolidated by update-dependencies.yaml. Security updates remain enabled.
27+
open-pull-requests-limit: 0
2228
schedule:
2329
interval: "cron"
2430
cronjob: "0 5 2 * *" # Second day of each month at 05:00 UTC
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
ecosystem=${1:?ecosystem is required}
6+
bundle_dir=${2:?bundle directory is required}
7+
8+
case "$ecosystem" in
9+
cargo)
10+
branch=automation/dependencies/cargo
11+
deterministic_re='^crates/.*Cargo\.toml$'
12+
agent_re='^crates/.*\.rs$'
13+
final_re='^crates/.*(Cargo\.toml|\.rs)$'
14+
;;
15+
npm)
16+
branch=automation/dependencies/npm
17+
deterministic_re='^crates/string-offsets/js/package(-lock)?\.json$'
18+
agent_re='^crates/string-offsets/js/.*\.(js|cjs|mjs|ts)$'
19+
final_re='^crates/string-offsets/js/(package(-lock)?\.json|.*\.(js|cjs|mjs|ts))$'
20+
;;
21+
github-actions)
22+
branch=automation/dependencies/github-actions
23+
deterministic_re='^\.github/workflows/.*\.ya?ml$'
24+
agent_re='a^'
25+
final_re='^\.github/workflows/.*\.ya?ml$'
26+
;;
27+
*)
28+
echo "unsupported ecosystem: $ecosystem" >&2
29+
exit 2
30+
;;
31+
esac
32+
33+
state=$(tr -d '[:space:]' < "$bundle_dir/state")
34+
case "$state" in
35+
noop)
36+
echo "No $ecosystem dependency changes; leaving branch and PR untouched."
37+
exit 0
38+
;;
39+
ready) ;;
40+
*)
41+
echo "generator did not produce an applicable $ecosystem artifact" >&2
42+
exit 1
43+
;;
44+
esac
45+
46+
for path in deterministic.patch final.patch title.txt body.md; do
47+
if [[ ! -f "$bundle_dir/$path" ]]; then
48+
echo "missing artifact file: $path" >&2
49+
exit 1
50+
fi
51+
done
52+
53+
tree_from_patch() {
54+
local patch=$1
55+
local index
56+
index=$(mktemp)
57+
rm -f "$index"
58+
GIT_INDEX_FILE=$index git read-tree HEAD
59+
if [[ -s "$patch" ]]; then
60+
GIT_INDEX_FILE=$index git apply --cached --binary "$patch"
61+
fi
62+
GIT_INDEX_FILE=$index git write-tree
63+
rm -f "$index"
64+
}
65+
66+
validate_paths() {
67+
local label=$1
68+
local regex=$2
69+
local paths=$3
70+
local violations
71+
violations=$(grep -vE "$regex" "$paths" || true)
72+
if [[ -n "$violations" ]]; then
73+
echo "$label contains paths outside the allowlist:" >&2
74+
printf '%s\n' "$violations" >&2
75+
exit 1
76+
fi
77+
}
78+
79+
baseline_tree=$(tree_from_patch "$bundle_dir/deterministic.patch")
80+
final_tree=$(tree_from_patch "$bundle_dir/final.patch")
81+
git diff --name-only HEAD "$baseline_tree" > "$RUNNER_TEMP/deterministic-paths.txt"
82+
git diff --name-only "$baseline_tree" "$final_tree" > "$RUNNER_TEMP/agent-paths.txt"
83+
git diff --name-only HEAD "$final_tree" > "$RUNNER_TEMP/final-paths.txt"
84+
git diff --diff-filter=AD --name-only HEAD "$final_tree" > "$RUNNER_TEMP/structural-paths.txt"
85+
86+
validate_paths "deterministic update" "$deterministic_re" "$RUNNER_TEMP/deterministic-paths.txt"
87+
validate_paths "agent update" "$agent_re" "$RUNNER_TEMP/agent-paths.txt"
88+
validate_paths "final update" "$final_re" "$RUNNER_TEMP/final-paths.txt"
89+
90+
if [[ -s "$RUNNER_TEMP/structural-paths.txt" ]]; then
91+
echo "dependency update added or deleted files:" >&2
92+
cat "$RUNNER_TEMP/structural-paths.txt" >&2
93+
exit 1
94+
fi
95+
96+
if [[ ! -s "$RUNNER_TEMP/final-paths.txt" ]]; then
97+
echo "Final $ecosystem patch is empty; leaving branch and PR untouched."
98+
exit 0
99+
fi
100+
101+
git config user.name "github-actions[bot]"
102+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
103+
104+
expected_oid=
105+
remote_oid=$(git ls-remote --heads origin "refs/heads/$branch" | awk '{print $1}')
106+
if [[ -n "$remote_oid" ]]; then
107+
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
108+
expected_oid=$(git rev-parse "refs/remotes/origin/$branch")
109+
non_bot=$(git log "origin/main..refs/remotes/origin/$branch" --format='%ae%x09%ce' |
110+
awk -F '\t' '$1 != "41898282+github-actions[bot]@users.noreply.github.com" || $2 != "41898282+github-actions[bot]@users.noreply.github.com"')
111+
if [[ -n "$non_bot" ]]; then
112+
echo "$branch contains non-bot commits; refusing to overwrite it" >&2
113+
printf '%s\n' "$non_bot" >&2
114+
exit 1
115+
fi
116+
fi
117+
118+
prs=$(gh pr list \
119+
--head "$branch" \
120+
--state open \
121+
--limit 2 \
122+
--json number,isDraft,author,baseRefName)
123+
count=$(jq 'length' <<<"$prs")
124+
if [[ "$count" -gt 1 ]]; then
125+
echo "multiple open PRs found for $branch" >&2
126+
exit 1
127+
fi
128+
129+
if [[ "$count" -eq 1 ]]; then
130+
number=$(jq -r '.[0].number' <<<"$prs")
131+
author=$(jq -r '.[0].author.login' <<<"$prs")
132+
draft=$(jq -r '.[0].isDraft' <<<"$prs")
133+
base=$(jq -r '.[0].baseRefName' <<<"$prs")
134+
if [[ "$author" != "github-actions[bot]" || "$draft" != "true" || "$base" != "main" ]]; then
135+
echo "open PR for $branch is not the workflow's own main-targeting draft" >&2
136+
exit 1
137+
fi
138+
fi
139+
140+
if [[ $(wc -l < "$bundle_dir/title.txt") -ne 1 || $(wc -c < "$bundle_dir/title.txt") -gt 200 ]]; then
141+
echo "invalid PR title" >&2
142+
exit 1
143+
fi
144+
title=$(tr -d '\n' < "$bundle_dir/title.txt")
145+
146+
cat "$bundle_dir/body.md" > "$RUNNER_TEMP/pr-body.md"
147+
cat >> "$RUNNER_TEMP/pr-body.md" <<'EOF'
148+
149+
---
150+
151+
This draft is a reference implementation using the workflow `GITHUB_TOKEN`. GitHub does not trigger new workflow runs from branch pushes or PR mutations made with that token, so a maintainer must trigger or approve CI. Fully autonomous post-PR repair requires a proven least-privilege GitHub App installation token or PAT scoped to the reserved dependency branches and their pull requests.
152+
EOF
153+
154+
git checkout -B "$branch"
155+
git apply --index --binary "$bundle_dir/final.patch"
156+
git commit --no-verify \
157+
-m "$title" \
158+
-m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>"
159+
160+
if [[ -n "$expected_oid" ]]; then
161+
git push --force-with-lease="refs/heads/$branch:$expected_oid" origin "HEAD:refs/heads/$branch"
162+
else
163+
git push --force-with-lease="refs/heads/$branch:" origin "HEAD:refs/heads/$branch"
164+
fi
165+
166+
if [[ "$count" -eq 1 ]]; then
167+
jq -n \
168+
--arg title "$title" \
169+
--rawfile body "$RUNNER_TEMP/pr-body.md" \
170+
'{title: $title, body: $body}' |
171+
gh api -X PATCH "repos/$GITHUB_REPOSITORY/pulls/$number" --input - >/dev/null
172+
echo "Updated draft PR #$number."
173+
else
174+
gh pr create \
175+
--base main \
176+
--head "$branch" \
177+
--draft \
178+
--title "$title" \
179+
--body-file "$RUNNER_TEMP/pr-body.md"
180+
fi

0 commit comments

Comments
 (0)