@@ -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}'
7777work=" $( mktemp -d) " || fail " could not create a work directory"
7878trap ' 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.
95118order_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
148185git 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+
150232while 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"
195278done < " $work /changed"
196279
280+ candidate_tree=" $tree_sha "
197281if (( ${# 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