Skip to content

Commit 80943d2

Browse files
authored
fix(template-sync): preserve pin order across catalogue migration (#327)
* fix(template-sync): preserve pin order across catalogue migration * fix(template-sync): reject catalogue rollbacks across files * fix(template-sync): order cross-file pins in the final tree * docs(template-sync): explain ambiguous cross-file replacements
1 parent 42e9086 commit 80943d2

3 files changed

Lines changed: 540 additions & 205 deletions

File tree

‎.github/scripts/replace-template-sync-commit.sh‎

Lines changed: 110 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,17 @@ remote_sha="$(jq -er '.object.sha' <<<"$remote_ref")" || fail "generated remote
6363
[[ "$remote_sha" == "$current_sha" ]] ||
6464
fail "generated remote branch moved after the sync action (expected $current_sha, found $remote_sha)"
6565

66-
# Shared-workflow pins never move backwards (#1239). Template sync copies the TEMPLATE's
67-
# devantler-tech/actions pins over the target's, so a template that lags its consumer would
66+
# Catalogue pins never move backwards (#1239, #256). Template sync copies the TEMPLATE's
67+
# pins over the target's, so a template that lags its consumer would
6868
# downgrade it: devantler-tech/wedding-app#332 replaced three v13.6.0 pins with v13.5.1 while every
6969
# check stayed green, because both pins were approved. Each changed pin is therefore ordered against
7070
# the target's pin for the same path with GitHub's compare API (commit ancestry, never the mutable
7171
# `# vX.Y.Z` comment). Where the template's pin is behind or diverged, the target's whole line is
7272
# kept; only that corrected tree is signed. An ordering the API cannot answer, a regressive line
7373
# the template also reshaped, or a line moved onto the older of two pins the target keeps for one
74-
# path fails closed before anything is signed.
75-
actions_repo="devantler-tech/actions"
76-
pin_pattern="${actions_repo}(/[^@[:space:]]*)?@[0-9a-f]{40}"
74+
# path fails closed before anything is signed. Comparisons stay within each catalogue's history.
75+
# Moving from the retired actions catalogue to .github is allowed; moving back is refused.
76+
pin_pattern='devantler-tech/(actions|\.github)(/[^@[:space:]]*)?@[0-9a-f]{40}'
7777
work="$(mktemp -d)" || fail "could not create a work directory"
7878
trap 'rm -rf "$work"' EXIT
7979

@@ -92,16 +92,53 @@ ref_count() {
9292
{ grep -oF -- "$3" <<<"$content" || true; } | wc -l
9393
}
9494

95+
# Return the owning repository of a matched, immutable catalogue reference.
96+
pin_repository() {
97+
local path="${1%@*}"
98+
path="${path#devantler-tech/}"
99+
printf 'devantler-tech/%s\n' "${path%%/*}"
100+
}
101+
102+
# Pair only catalogue paths that moved. Reusable workflows keep their path; public composite
103+
# actions move from the old root into .github's actions/.
104+
pin_component() {
105+
local path="${1%@*}"
106+
case "$path" in
107+
devantler-tech/actions/.github/workflows/*) printf 'workflow:%s\n' "${path#devantler-tech/actions/.github/workflows/}" ;;
108+
devantler-tech/.github/.github/workflows/*) printf 'workflow:%s\n' "${path#devantler-tech/.github/.github/workflows/}" ;;
109+
devantler-tech/actions/.github/*) printf '%s\n' "$path" ;;
110+
devantler-tech/actions/*) printf 'action:%s\n' "${path#devantler-tech/actions/}" ;;
111+
devantler-tech/.github/actions/*) printf 'action:%s\n' "${path#devantler-tech/.github/actions/}" ;;
112+
*) printf '%s\n' "$path" ;;
113+
esac
114+
}
115+
116+
# order_pins <target-ref> <template-ref>: order within one history, or allow the explicit
117+
# forward catalogue migration. Never compare commits across unrelated repositories.
95118
order_pins() {
96-
# order_pins <target-pin> <template-ref>: the compare API's status for the template's pin
97-
# relative to the target's. Anything but a known status fails closed.
98-
local comparison status
99-
if ! comparison="$(gh api "repos/${actions_repo}/compare/${1}...${2##*@}" </dev/null)" ||
119+
local comparison status target_repo template_repo cache
120+
target_repo="$(pin_repository "$1")"
121+
template_repo="$(pin_repository "$2")"
122+
if [[ "$target_repo" != "$template_repo" ]]; then
123+
[[ "$target_repo" == devantler-tech/actions && "$template_repo" == devantler-tech/.github ]] ||
124+
fail "refusing to move ${1} back to the retired catalogue at ${2}; refusing to sign"
125+
printf 'migrated\n'
126+
return
127+
fi
128+
# The final-tree guard can revisit a pair already ordered by the per-file repair. Reuse that
129+
# immutable, repository-scoped result instead of spending another API request.
130+
cache="$work/order-${target_repo##*/}-${1##*@}-${2##*@}"
131+
if [[ -f "$cache" ]]; then
132+
status="$(cat "$cache")" || fail "could not read the verified pin ordering"
133+
elif ! comparison="$(gh api "repos/${target_repo}/compare/${1##*@}...${2##*@}" </dev/null)" ||
100134
! status="$(jq -er '.status' <<<"$comparison")"; then
101135
fail "could not order ${2} against the target's ${1}; refusing to sign an unverified pin change"
102136
fi
103137
case "$status" in
104-
ahead | identical | behind | diverged) printf '%s\n' "$status" ;;
138+
ahead | identical | behind | diverged)
139+
printf '%s\n' "$status" >"$cache" || fail "could not retain the verified pin ordering"
140+
printf '%s\n' "$status"
141+
;;
105142
*) fail "unexpected compare status '$status' for ${2}; refusing to sign" ;;
106143
esac
107144
}
@@ -147,18 +184,64 @@ restore_pin() {
147184

148185
git diff -z --name-only --no-renames "$base_sha" HEAD >"$work/changed" ||
149186
fail "could not list the files the sync commit changed"
187+
188+
# Emit revision, component, complete reference and occurrence count across changed paths. Unchanged
189+
# paths cancel out of the deltas; counting occurrences also covers consolidated duplicate calls.
190+
catalogue_counts() {
191+
local revision="$1" label="$2" path refs ref count
192+
while IFS= read -r -d '' path; do
193+
refs="$(pins_at "$revision" "$path")"
194+
[[ -n "$refs" ]] || continue
195+
while IFS= read -r ref; do
196+
count="$(ref_count "$revision" "$path" "$ref")" || fail "could not count $ref in $path"
197+
printf '%s\t%s\t%s\t%s\n' "$label" "$(pin_component "$ref")" \
198+
"$ref" "$count"
199+
done <<<"$refs"
200+
done <"$work/changed"
201+
}
202+
203+
# Compare lost and gained occurrences in the final local tree. Per-file repairs have already
204+
# restored safe lines; any remaining replacement across paths must itself be forward-only.
205+
guard_cross_file_pins() {
206+
local candidate="$1" old_ref new_ref status
207+
catalogue_counts "$base_sha" base >"$work/catalogue-counts"
208+
catalogue_counts "$candidate" head >>"$work/catalogue-counts"
209+
awk -F '\t' '
210+
{ counts[$1, $3] += $4; components[$3] = $2 }
211+
END {
212+
for (old_ref in components) {
213+
if (counts["base", old_ref] <= counts["head", old_ref]) continue
214+
for (new_ref in components) {
215+
if (components[old_ref] == components[new_ref] &&
216+
counts["head", new_ref] > counts["base", new_ref]) {
217+
print old_ref "\t" new_ref
218+
}
219+
}
220+
}
221+
}
222+
' "$work/catalogue-counts" >"$work/replacements" || fail "could not compare catalogue counts"
223+
while IFS=$'\t' read -r old_ref new_ref; do
224+
status="$(order_pins "$old_ref" "$new_ref")"
225+
case "$status" in
226+
ahead | identical | migrated) continue ;;
227+
*) fail "sync replaced ${old_ref} across files with ${new_ref}, which is ${status}; refusing to sign" ;;
228+
esac
229+
done <"$work/replacements"
230+
}
231+
150232
while IFS= read -r -d '' path; do
151233
new_pins="$(pins_at HEAD "$path")"
152234
[[ -n "$new_pins" ]] || continue
153235
old_pins="$(pins_at "$base_sha" "$path")"
154236
[[ -n "$old_pins" ]] || continue
155237
while IFS= read -r new_ref; do
156238
new_pin="${new_ref##*@}"
239+
component="$(pin_component "$new_ref")"
157240
# How many distinct pins the target keeps for this path. With more than one, a restore cannot
158241
# know which of them each line had, so any downgrade there fails closed instead of guessing.
159242
same_path=0
160243
while IFS= read -r old_ref; do
161-
if [[ "${old_ref%@*}" == "${new_ref%@*}" ]]; then same_path=$((same_path + 1)); fi
244+
if [[ "$(pin_component "$old_ref")" == "$component" ]]; then same_path=$((same_path + 1)); fi
162245
done <<<"$old_pins"
163246
if grep -qxF -- "$new_ref" <<<"$old_pins"; then
164247
# The target already carries this pin, so it is not new to the file. But where the target
@@ -170,18 +253,18 @@ while IFS= read -r -d '' path; do
170253
base_count="$(ref_count "$base_sha" "$path" "$new_ref")"
171254
((head_count > base_count)) || continue
172255
while IFS= read -r old_ref; do
173-
[[ "${old_ref%@*}" == "${new_ref%@*}" && "$old_ref" != "$new_ref" ]] || continue
174-
status="$(order_pins "${old_ref##*@}" "$new_ref")"
175-
[[ "$status" == ahead || "$status" == identical ]] ||
256+
[[ "$(pin_component "$old_ref")" == "$component" && "$old_ref" != "$new_ref" ]] || continue
257+
status="$(order_pins "$old_ref" "$new_ref")"
258+
[[ "$status" == ahead || "$status" == identical || "$status" == migrated ]] ||
176259
fail "${path} pins ${new_ref%@*} at more than one commit and the sync moved a line onto ${new_pin}, which is ${status} the target's ${old_ref##*@}; refusing to sign a downgrade it cannot isolate"
177260
done <<<"$old_pins"
178261
continue
179262
fi
180263
while IFS= read -r old_ref; do
181-
[[ "${old_ref%@*}" == "${new_ref%@*}" ]] || continue
182-
status="$(order_pins "${old_ref##*@}" "$new_ref")"
264+
[[ "$(pin_component "$old_ref")" == "$component" ]] || continue
265+
status="$(order_pins "$old_ref" "$new_ref")"
183266
case "$status" in
184-
ahead | identical) continue ;;
267+
ahead | identical | migrated) continue ;;
185268
esac
186269
((same_path == 1)) ||
187270
fail "${path} pins ${new_ref%@*} at more than one commit and the template's ${new_pin} is ${status} the target's ${old_ref##*@}; refusing to sign a downgrade it cannot isolate"
@@ -194,6 +277,7 @@ while IFS= read -r -d '' path; do
194277
done <<<"$new_pins"
195278
done <"$work/changed"
196279

280+
candidate_tree="$tree_sha"
197281
if ((${#corrected[@]} > 0)); then
198282
# Build the corrected tree locally, then have GitHub build it from the posted content, and sign
199283
# only if both agree byte for byte.
@@ -211,6 +295,15 @@ if ((${#corrected[@]} > 0)); then
211295
fail "could not encode the corrected $path"
212296
done
213297
local_tree="$(GIT_INDEX_FILE="$index" git write-tree)" || fail "could not write the corrected tree"
298+
candidate_tree="$local_tree"
299+
fi
300+
301+
# Check the actual proposed result, including restored pin lines, before GitHub receives any write.
302+
# This also catches a deleted current pin hidden by a migration elsewhere, and ordinary renamed
303+
# files whose replacement version is older even though their catalogue did not change.
304+
guard_cross_file_pins "$candidate_tree"
305+
306+
if ((${#corrected[@]} > 0)); then
214307
tree_payload="$(jq -n --arg base "$tree_sha" --argjson tree "$entries" '{base_tree:$base,tree:$tree}')" ||
215308
fail "could not build the corrected tree payload"
216309
tree_response="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/git/trees" --input - <<<"$tree_payload")" ||

0 commit comments

Comments
 (0)