Skip to content
Merged
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
110 changes: 96 additions & 14 deletions scripts/repin-sha.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
set -eu

usage() {
echo "usage: repin-sha.sh <depName> <newVersion> [dockerfile ...]" >&2
printf 'usage: repin-sha.sh <depName> <newVersion> [dockerfile ...]\n' >&2
exit 2
}

Expand All @@ -39,26 +39,83 @@ version=$2
shift 2
[ -n "$dep" ] && [ -n "$version" ] || usage

# The version is interpolated into the sed EXPRESSION below, and GNU sed's `e`
# flag and `e` command execute the pattern space as a shell command, so a
# version carrying `|` and `;` is arbitrary code execution here. It also reaches
# curl as part of a URL, where `{}`/`[]` trigger curl's own URL globbing.
# The value is not ours: it is whatever version a third-party datasource
# reports, so constrain it to the shape a version has before any use.
case $version in
*[!A-Za-z0-9._+~-]*)
printf 'repin: refusing version with unexpected characters: %s\n' "$version" >&2
exit 1
;;
esac

version_nov=${version#v}

if [ $# -eq 0 ]; then
set -- Dockerfile
fi

tmp=$(mktemp -d)
# shellcheck disable=SC2064 # expand $tmp now: it must not depend on later state
trap "rm -rf '$tmp'" EXIT INT TERM
# staged is the in-place rewrite target beside the Dockerfile, tracked here so
# the trap can remove it: it lives OUTSIDE $tmp by necessity (a rename must be
# same-filesystem), so the mktemp -d cleanup cannot reach it, and an interrupt
# between the copy and the rename would otherwise leave it in the working tree
# for Renovate to carry into a branch.
staged=
cleanup() {
rm -rf "$tmp"
[ -n "$staged" ] && rm -f "$staged"
return 0
}
trap cleanup EXIT INT TERM HUP

# resolve_target prints the real path of $1, following symlinks.
#
# It matters because the rewrite below commits by RENAME, which must land beside
# the actual file, and because a Dockerfile reached through a symlink has to be
# updated at its TARGET rather than replaced by a regular file — silently
# breaking whatever the symlink was arranged for.
#
# Neither realpath nor readlink is in POSIX (realpath arrived only in
# POSIX.1-2024) and this script is `#!/bin/sh` synced across every repo, so both
# are probed rather than assumed. If neither exists the path is used as given and
# the caller is told, because degrading SILENTLY here would replace a symlink
# with a regular file and look like it worked.
resolve_target() {
if command -v realpath >/dev/null 2>&1; then
realpath "$1"
elif command -v readlink >/dev/null 2>&1 && readlink -f "$1" >/dev/null 2>&1; then
readlink -f "$1"
else
if [ -L "$1" ]; then
printf 'repin: %s: no realpath or readlink -f; a symlinked Dockerfile will be REPLACED by a regular file\n' "$1" >&2
fi
printf '%s\n' "$1"
fi
}

updated=0

for dockerfile in "$@"; do
[ -f "$dockerfile" ] || continue

dockerfile_target=$(resolve_target "$dockerfile") || {
printf 'repin: %s: cannot resolve target path\n' "$dockerfile" >&2
exit 1
}

# Emit "<ARG name> <url template>" for every marker naming this dep. The
# marker must sit on the line immediately above its ARG so the pairing is
# unambiguous in a file that carries several pins.
awk -v dep="$dep" '
/^#[[:space:]]*repin:/ {
if (pending_dep != "") {
printf "repin: marker at %s:%d is not followed by an ARG assignment\n", FILENAME, pending_line > "/dev/stderr"
exit 3
}
d = ""; u = ""
for (i = 1; i <= NF; i++) {
if ($i ~ /^dep=/) { d = substr($i, 5) }
Expand All @@ -85,26 +142,33 @@ for dockerfile in "$@"; do
pending_dep = ""
next
}
' "$dockerfile" >"$tmp/pins" || exit $?
END {
if (pending_dep != "") {
printf "repin: marker at %s:%d is not followed by an ARG assignment\n", FILENAME, pending_line > "/dev/stderr"
exit 3
}
}
' "$dockerfile_target" >"$tmp/pins" || exit $?

while read -r name url; do
[ -n "$name" ] || continue

# Placeholder expansion is a literal substitution on the marker's own
# text, so a URL is only ever built from the Dockerfile plus the version
# Renovate reports.
# A sed replacement is NOT a literal context ('&' re-inserts the match,
# '\1' a group, '|' closes the command), so this is safe only because the
# version was parsed to [A-Za-z0-9._+~-] at the argument boundary above.
# The URL is then built from the Dockerfile marker plus that parsed value.
resolved=$(printf '%s\n' "$url" \
| sed -e "s|{version}|$version|g" -e "s|{version_nov}|$version_nov|g")

case $resolved in
https://*) ;;
*)
echo "repin: $name: refusing non-https URL: $resolved" >&2
printf 'repin: %s: refusing non-https URL: %s\n' "$name" "$resolved" >&2
exit 1
;;
esac

echo "repin: $dockerfile: $name <- $resolved"
printf 'repin: %s: %s <- %s\n' "$dockerfile" "$name" "$resolved"
curl --proto '=https' --proto-redir '=https' --tlsv1.2 \
--connect-timeout 20 --max-time 300 --retry 3 --retry-delay 5 \
-fsSL -o "$tmp/artifact" "$resolved"
Expand All @@ -113,7 +177,7 @@ for dockerfile in "$@"; do
case $sha in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*) ;;
*)
echo "repin: $name: sha256sum produced no digest" >&2
printf 'repin: %s: sha256sum produced no digest\n' "$name" >&2
exit 1
;;
esac
Expand All @@ -122,18 +186,36 @@ for dockerfile in "$@"; do
# file can match; the optional trailing group preserves an inline
# Renovate anchor comment (ARG X=<sha> # tool v1.2.3).
sed -E "s|^(ARG ${name}=)[0-9a-f]{64}([[:space:]].*)?\$|\1${sha}\2|" \
"$dockerfile" >"$tmp/rewritten"
"$dockerfile_target" >"$tmp/rewritten"

if ! grep -qE "^ARG ${name}=${sha}([[:space:]]|\$)" "$tmp/rewritten"; then
echo "repin: $name: no 64-hex pin to rewrite in $dockerfile" >&2
printf 'repin: %s: no 64-hex pin to rewrite in %s\n' "$name" "$dockerfile" >&2
exit 1
fi

cat "$tmp/rewritten" >"$dockerfile"
# Replace atomically. '>' truncates the target before the first byte lands,
# so a killed postUpgradeTask or an ENOSPC leaves a truncated Dockerfile in
# the branch Renovate commits. Stage beside the TARGET (the mktemp -d above
# is a different filesystem, so a rename out of it cannot work) and rename
# over it; copying the original first carries its mode across the replace,
# which `cp -p` does portably where `chmod --reference` is GNU-only.
#
# The staged name carries $$ so two runs cannot collide on it. A fixed
# `.repin.tmp` was the earlier shape and it is only safe while nothing else
# runs concurrently — an assumption a fleet-wide script should not make when
# the fix costs six characters. A leftover matches neither postUpgradeTasks
# fileFilter (Dockerfile, **/Dockerfile), so it can never be committed.
# shell.md, "Temp files and atomic writes".
staged="$dockerfile_target.repin.$$.tmp"
rm -f "$staged"
cp -p "$dockerfile_target" "$staged"
cat "$tmp/rewritten" >"$staged"
mv -f "$staged" "$dockerfile_target"
staged= # renamed away: nothing left for the trap to remove
updated=$((updated + 1))
done <"$tmp/pins"
done

if [ "$updated" -eq 0 ]; then
echo "repin: no pin declares dep=$dep; nothing to do"
printf 'repin: no pin declares dep=%s; nothing to do\n' "$dep"
fi
Loading