Skip to content

Commit b285c0e

Browse files
committed
Refuse a pin move that would roll the source back
Rehearsing the bot against the real upstreams found newlib pinned to the head of `newlib-4.6.0` while `vita`, its default branch, sits three major versions behind it: following `vita` would have quietly undone the 4.6 upgrade on the first nightly, and the only sign would have been whatever broke afterwards. The config now names the branch the work is actually on. That was luck -- somebody happened to look. A pin only moves to a revision that contains it, so 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 0fda713 commit b285c0e

3 files changed

Lines changed: 93 additions & 2 deletions

File tree

cmake/pin-tracking.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"schema": 1,
33
"_comment": "Which git-pinned components the pin-bump bot moves forward, and which it leaves alone. Every component pinned by repository plus revision must appear in exactly one of the two maps: scripts/bump-pins.py refuses to run otherwise, so a new pin cannot go unnoticed in either direction.",
44
"tracked": {
5-
"newlib": {"branch": "vita"},
5+
"newlib": {"branch": "newlib-4.6.0"},
66
"samples": {"branch": "master"},
77
"headers": {"branch": "master"},
88
"toolchain": {"branch": "master"},

scripts/bump-pins.py

Lines changed: 43 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,41 @@ 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+
)
150+
return ancestor.returncode == 0
151+
finally:
152+
shutil.rmtree(probe, ignore_errors=True)
153+
154+
119155
def rewrite_pin(text, variable, old, new):
120156
pattern = re.compile(
121157
r"(set\(\s*" + re.escape(variable) + r"_TAG\s+)" + re.escape(old) + r"(?=[\s)])"
@@ -160,6 +196,12 @@ def bump(components_path, tracking_path, overrides, dry_run):
160196
head = resolve_branch(repository, tracked[name])
161197
if head == component["pin"]:
162198
continue
199+
if not contains(repository, component["pin"], tracked[name]):
200+
raise BumpError(
201+
f"{name}: {tracked[name]} is at {head}, which does not contain "
202+
f"the pinned {component['pin']}; moving it would roll the "
203+
"source back"
204+
)
163205
text = rewrite_pin(text, component["variable"], component["pin"], head)
164206
moves.append(
165207
{

tests/ci/test-bump-pins.sh

Lines changed: 49 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,30 @@ 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+
145194
# The pins the config leaves alone stay exactly as the file declares them,
146195
# and are never resolved: no override is given for any of them here.
147196
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)