Skip to content

Commit 1a97a87

Browse files
committed
Refuse a pin move that would roll the source back
Rehearsing the bot against the real upstreams found newlib's `vita` branch 2445 commits behind the pinned revision, missing the 4.3, 4.4, 4.5 and 4.6 merges the SDK is actually built from. Following it would have undone the 4.6 upgrade on the first nightly, at 03:00, and the only sign would have been whatever broke afterwards. That is fixed where it was broken -- `vita` now holds what the SDK builds -- but it was luck that somebody looked. So a pin only moves to a revision that contains it. A force-push upstream, a branch that is not where the work happens, or a pin taken from somewhere else stops the bot and names both revisions. The check asks the remote for commits and nothing else: the whole history of the largest leaf is a few megabytes and about three seconds, and only a pin that is actually moving pays for it.
1 parent 7f6b617 commit 1a97a87

2 files changed

Lines changed: 114 additions & 1 deletion

File tree

scripts/bump-pins.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33

44
import argparse
55
import json
6-
import os
76
import re
7+
import shutil
88
import subprocess
99
import sys
10+
import tempfile
1011

1112
COMPONENTS_PATH = "cmake/Components.cmake"
1213
TRACKING_PATH = "cmake/pin-tracking.json"
@@ -116,6 +117,50 @@ def resolve_branch(repository, branch):
116117
return result.stdout.split()[0]
117118

118119

120+
def contains(repository, pin, branch):
121+
"""Whether what the branch holds now was built on top of the pin.
122+
123+
A pin that moves to a revision not descended from it is a rollback:
124+
a force-push upstream, a branch that is not where the work happens,
125+
a pin taken from somewhere else. The bot refuses and a person looks.
126+
"""
127+
128+
probe = tempfile.mkdtemp(prefix="bump-pins-")
129+
try:
130+
subprocess.run(["git", "init", "--quiet", probe], check=True)
131+
# tree:0 asks for commits and nothing else: the whole history of
132+
# the largest leaf arrives in a few megabytes and a few seconds.
133+
fetch = subprocess.run(
134+
[
135+
"git", "-C", probe, "fetch", "--quiet", "--filter=tree:0",
136+
"--no-tags", repository,
137+
f"+refs/heads/{branch}:refs/heads/tracked", pin,
138+
],
139+
capture_output=True,
140+
text=True,
141+
)
142+
if fetch.returncode != 0:
143+
raise BumpError(
144+
f"{repository} cannot serve {pin} and {branch} together: "
145+
f"{fetch.stderr.strip() or 'git fetch failed'}"
146+
)
147+
ancestor = subprocess.run(
148+
["git", "-C", probe, "merge-base", "--is-ancestor", pin, "refs/heads/tracked"],
149+
capture_output=True,
150+
text=True,
151+
)
152+
# 1 is the answer "no"; anything else is git saying it could not
153+
# work out the question, which must not read as a rollback.
154+
if ancestor.returncode not in (0, 1):
155+
raise BumpError(
156+
f"{repository}: cannot tell whether {branch} contains {pin}: "
157+
f"{ancestor.stderr.strip() or 'git merge-base failed'}"
158+
)
159+
return ancestor.returncode == 0
160+
finally:
161+
shutil.rmtree(probe, ignore_errors=True)
162+
163+
119164
def rewrite_pin(text, variable, old, new):
120165
pattern = re.compile(
121166
r"(set\(\s*" + re.escape(variable) + r"_TAG\s+)" + re.escape(old) + r"(?=[\s)])"
@@ -160,6 +205,12 @@ def bump(components_path, tracking_path, overrides, dry_run):
160205
head = resolve_branch(repository, tracked[name])
161206
if head == component["pin"]:
162207
continue
208+
if not contains(repository, component["pin"], tracked[name]):
209+
raise BumpError(
210+
f"{name}: {tracked[name]} is at {head}, which does not contain "
211+
f"the pinned {component['pin']}; moving it would roll the "
212+
"source back"
213+
)
163214
text = rewrite_pin(text, component["variable"], component["pin"], head)
164215
moves.append(
165216
{

tests/ci/test-bump-pins.sh

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ bump() {
2020
--components "$components" --tracking "$tracking" "$@"
2121
}
2222

23+
seed_pin() {
24+
python3 - "$components" "$1" "$2" <<'PYEOF'
25+
import re, sys
26+
path, name, revision = sys.argv[1], sys.argv[2], sys.argv[3]
27+
variable = name.upper().replace("-", "_")
28+
text, replaced = re.subn(
29+
r"(set\(\s*" + variable + r"_TAG\s+)\S+",
30+
lambda match: match.group(1) + revision,
31+
open(path).read(),
32+
count=1,
33+
)
34+
assert replaced == 1, f"no {variable}_TAG to seed"
35+
open(path, "w").write(text)
36+
PYEOF
37+
}
38+
2339
# The upstreams the bot follows, straight from the file under test: a
2440
# component added to the config is exercised here without editing this test.
2541
mapfile -t tracked < <(python3 - "$tracking" <<'PYEOF'
@@ -48,6 +64,10 @@ for entry in "${tracked[@]}"; do
4864
git -C "$upstream" commit --quiet -m "first"
4965
upstream_of[$name]=$upstream
5066
overrides+=(--repository "$name=$upstream")
67+
# The pins start where these fixtures start: the real ones name
68+
# revisions no fixture can serve, and a bump is a move from something
69+
# the upstream actually holds.
70+
seed_pin "$name" "$(git -C "$upstream" rev-parse HEAD)"
5171
done
5272

5373
pin_of() {
@@ -61,6 +81,11 @@ PYEOF
6181
}
6282

6383
# Everything moves once, to exactly what the upstream branches hold.
84+
for entry in "${tracked[@]}"; do
85+
read -r name _ <<< "$entry"
86+
echo "moved" > "${upstream_of[$name]}/file"
87+
git -C "${upstream_of[$name]}" commit --quiet -am "moved"
88+
done
6489
output=$(bump "${overrides[@]}")
6590
grep -q "bump-pins: ${#tracked[@]} pin(s) moved" <<< "$output" || {
6691
printf 'the first bump did not move every tracked pin: %s\n' "$output" >&2
@@ -142,6 +167,43 @@ cmp -s "$components" "$temporary_root/before-dry" || {
142167
exit 1
143168
}
144169

170+
# A branch that no longer contains the pin is a rollback, whatever the
171+
# reason -- a force-push, or a pin that lives on another branch. The bot
172+
# refuses and names both revisions instead of quietly going backwards.
173+
git -C "$moved_upstream" branch keep
174+
git -C "$moved_upstream" reset --quiet --hard HEAD~2
175+
cp "$components" "$temporary_root/before-rollback"
176+
if output=$(bump "${overrides[@]}" 2>&1); then
177+
printf 'the bot accepted a branch that had gone backwards\n' >&2
178+
exit 1
179+
fi
180+
grep -q "$moved_name" <<< "$output" || {
181+
printf 'the refusal does not name the rolled back component: %s\n' "$output" >&2
182+
exit 1
183+
}
184+
grep -q "$(pin_of "$moved_name")" <<< "$output" || {
185+
printf 'the refusal does not name the pin it kept: %s\n' "$output" >&2
186+
exit 1
187+
}
188+
cmp -s "$components" "$temporary_root/before-rollback" || {
189+
printf 'a refused bump rewrote Components.cmake\n' >&2
190+
exit 1
191+
}
192+
git -C "$moved_upstream" reset --quiet --hard keep
193+
194+
# A pin git cannot resolve is git failing to answer, not an answer: it
195+
# must not be reported as a source going backwards.
196+
seed_pin "$moved_name" "not-a-revision"
197+
if output=$(bump "${overrides[@]}" 2>&1); then
198+
printf 'the bot ran with a pin that is not a revision\n' >&2
199+
exit 1
200+
fi
201+
if grep -q 'roll the source back' <<< "$output"; then
202+
printf 'an unresolvable pin was reported as a rollback: %s\n' "$output" >&2
203+
exit 1
204+
fi
205+
seed_pin "$moved_name" "$moved_head"
206+
145207
# The pins the config leaves alone stay exactly as the file declares them,
146208
# and are never resolved: no override is given for any of them here.
147209
for name in $(python3 -c 'import json,sys; print(" ".join(sorted(json.load(open(sys.argv[1]))["untracked"])))' "$tracking"); do

0 commit comments

Comments
 (0)