Repository navigation
Expand file tree
/
Copy pathsync-build-config.sh
More file actions
executable file
·555 lines (528 loc) · 32.4 KB
/
Copy pathsync-build-config.sh
File metadata and controls
executable file
·555 lines (528 loc) · 32.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
#!/usr/bin/env bash
# Distribute the FS-GG org-shared .NET build configuration into a consumer repo.
#
# RETIRED AS A LIVE FABRIC (#1262 / ADR-0062). Build config now ships as the FS.GG.Kit package: receivers
# materialize dist/dotnet/'s .props from a pinned FS.GG.Kit (FsggKitMaterializeBuildConfig) instead of
# taking them by byte-copy. So the apply / --check / --adopt modes below are no longer wired to any
# workflow — build-config-propagate.yml and the reusable contract-coherence drift step are gone, and each
# receiver's own `build-config-drift` job now asserts its committed .props match the package. This script
# SURVIVES because it is the DERIVE SOURCE for the kit's build-config members: src/FS.GG.Kit/stage-kit.sh
# reads the FILES=() list below (ADR-0058, derive-don't-restate) and MaterializeKit mirrors the marker/
# adopt safety. Edit FILES or the marker here and the package follows. The executable modes are kept
# working (and self-tested) as the documented contract of that derive, not because a workflow calls them.
#
# Source of truth: this repo's dist/dotnet/ (Directory.Build.props, Directory.Packages.props).
# See docs/build/README.md for the adoption model and the unified lockfile-restore-enforcement gate
# (ADR-0006). (.config/dotnet-tools.json used to live here too; #1077 moved it to the coordination-kit.)
#
# Usage:
# scripts/sync-build-config.sh <path-to-consumer-repo> # write/update the managed files
# scripts/sync-build-config.sh --check <path-to-consumer-repo> # drift check only; exit 1 on drift
# scripts/sync-build-config.sh --adopt <path-to-consumer-repo> # first-time: move an existing
# # non-canonical *.props to *.local.props
# # (imported), then write the
# # canonical files
#
# Hand-authored-file safety (.github#387): a *.props carries a marker, so apply REFUSES to overwrite
# an unmarked one (run --adopt to move it to an imported *.local.props).
#
# --check is the hook the reusable coherence workflow (.github#18) calls in every repo's CI:
# a repo that has hand-edited a managed file fails the gate.
#
# --check compares against THE COMMIT THE RECEIVER IS PINNED TO, not against main (.github#592). A
# receiver that is merely BEHIND is green; only a hand-edited managed file is red. See the PIN_REL
# block below for why — it is the whole point of that change, and reading it before touching --check
# will save you from re-introducing a race that merge-froze this org twice.
set -euo pipefail
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../dist/dotnet" && pwd)"
# Absolute, resolved path to this script. The --check failure message must name a command the reader
# can actually RUN, and the reader is almost never standing where this script lives (.github#633):
# every receiver's CI checks this repo out into a scratch dir and invokes it against a separate
# checkout of the repo under test, so "scripts/sync-build-config.sh" is No such file or directory in
# the repo whose gate just went red. $0 alone is not enough either — it is relative to the invoking
# cwd, so it stops resolving the moment the reader cd's anywhere.
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
# Managed files, relative to the repo root.
#
# ADDING A FILE HERE MERGE-FREEZES EVERY RECEIVER THAT DOES NOT ALREADY HAVE IT. `--check` treats a
# missing managed file as DRIFT (see the `check` arm below), and the drift check is a REQUIRED check
# in adopting repos. So the moment a name lands in this list, every repo without that file goes red
# on a check it cannot make green from its own PR — which is a merge freeze, not a nudge.
#
# This is not hypothetical. #499 moved the source of truth in Directory.Build.props and FS.GG.SDD has
# been unable to merge ANYTHING since (.github#379) — a finished, green PR sat blocked for hours. The
# rule that avoids it is the same one ADR-0032 §3 states for lock files: THE RECEIVERS ADOPT FIRST,
# and the shared config starts enforcing it LAST.
#
# So the order for any new managed file is:
# 1. add it under dist/dotnet/ (harmless: not in this list, so nothing checks it yet)
# 2. one PR per receiver, adopting it
# 3. ONLY THEN add its name here — and now the gate is asserting something already true
#
# `global.json` IS NOT IN THIS LIST, AND THAT IS A DECISION, NOT A PENDING STEP (.github#903).
#
# It is distributed under dist/dotnet/ and deliberately UNMANAGED: per-repo SDK bands are legitimate.
# The three-step order above does not apply to it — there is no step 3 waiting to be taken. #561
# once carried one; #903 decided against it and closed the question. tests/sync-build-config asserts
# the name stays out, because a comment is not a gate (#266).
#
# WHY, in one line: nothing fans a canonical bump out, so divergence is the STEADY STATE of this
# file, not an accident — and it re-opens on every SDK patch. Coherence here is not a state you
# reach once. The four receivers adopted byte-identically at the canonical 10.0.301 of the day
# (#561's children); Renovate then bumped the CANONICAL to 10.0.302 out from under them (bff95e4,
# #804) and bumped Game and Governance in their own repos, leaving Rendering, SDD and Audio holding
# the ORIGINAL canonical bytes. `--check` compares CONTENT, so enforcing that day would have frozen
# three repos, every one of which had adopted exactly as instructed. A gate that reds three repos
# for correctly adopting is measuring the wrong thing.
#
# Two more reasons #903 landed where it did: `rollForward: latestFeature` makes the pin a FLOOR, so
# band-coherence buys less than it looks like it buys; and enforcement is incompatible with
# Renovate's per-repo bumps (#678) — managing this file means taking it out of Renovate's reach in
# five receivers, a standing cost, forever, for a floor.
#
# TO CHANGE THIS, RE-OPEN #903. Do not "finish the job" — the job was dropped on purpose. If you do
# re-open it, the precondition is CONTENT coherence, not presence, and it must be re-measured rather
# than read off any census in a comment (this one included — it is a dated observation, and the
# thing it observes moves):
# for r in Game Rendering SDD Governance Audio; do
# gh api "repos/FS-GG/FS.GG.$r/contents/global.json" --jq '.content' 2>/dev/null | base64 -d \
# | diff -q - dist/dotnet/global.json >/dev/null && echo "$r: MATCHES" || echo "$r: DRIFTS"
# done
FILES=(
"Directory.Build.props"
"Directory.Packages.props"
)
# .config/dotnet-tools.json is NO LONGER a build-config file (#1077). The engine tool manifest that
# lets a repo restore `fs.gg.coord.cli` moved to the coordination-kit (registry/repos.yml `kit:`,
# kind: config), which reaches all six kit receivers — build-config reached only four, so templates
# and audio held the fsgg-coord shim with no engine to exec. It is distributed by scripts/coordination-sync
# now; every managed file here is once again a `.props`, which is why the non-.props apply/adopt arm
# and the JSON well-formedness guard that used to live here are gone with it.
# A canonical synced file carries this marker; a hand-authored repo file does not.
MARKER="Source of truth: FS-GG/.github"
# ------------------------------------------------------------------------------------------------
# THE PROVENANCE PIN (.github#592) — why --check does NOT compare against main.
#
# Every receiver's `gate.yml` checks THIS repo out at `ref: main` and runs `--check`. So while --check
# compared the receiver's files against `dist/dotnet/` AS OF MAIN, its verdict was a function of
# ANOTHER REPO'S MOVING BRANCH, evaluated at whatever moment CI happened to run. Two things followed,
# and both are worse than they sound:
#
# * A receiver could not make the check green FROM ITS OWN PR. Nothing in the PR's tree determined
# the answer.
# * The instant anything landed in dist/dotnet/ here, EVERY open PR in EVERY adopting repo went red
# on a REQUIRED check, through no fault of its own and with no change to its branch.
#
# That is not a drift check, it is a race — and it fired twice. #499 moved Directory.Build.props and
# FS.GG.SDD could merge NOTHING for hours (FS.GG.SDD#379). #536 then edited the same file's XML
# COMMENT — no MSBuild element changed — and red-lit a green, in-flight PR (FS.GG.SDD#380). A comment
# was enough.
#
# So the receiver now records WHICH .github commit its managed files came from, and --check compares
# against THAT commit's dist/dotnet/. The verdict becomes a pure function of the receiver's own tree —
# its files and its pin, both of which arrive together on the PR's own merge ref. Nothing .github does
# to main can move it. The race is gone BY CONSTRUCTION, not by being faster than it.
#
# What the two outcomes now mean — this is the entire point of the change:
#
# files == dist/dotnet@pin -> GREEN. The copy is faithful. It may be BEHIND main, and that is FINE:
# being behind is not a defect in the PR, and build-config-propagate.yml
# (#626) already has a rolling, auto-merging sync PR open to fix it.
# files != dist/dotnet@pin -> RED. A managed file was hand-edited. That IS a defect in the PR, and
# the author can fix it from their own branch — which is what a required
# check is for.
#
# Staleness is therefore VISIBLE (the pin is a committed file you can read) and remediated by a bot,
# rather than "enforced" by freezing everyone's merges. #592 chose that inversion deliberately: for a
# REQUIRED check, stale-until-someone-merges beats merge-frozen-by-default.
#
# THIS FILE IS NOT IN `FILES`, AND MUST NEVER BE ADDED TO IT. FILES is the byte-identity set, and the
# pin is per-receiver by construction (it holds a SHA), so byte-identity is meaningless for it. Worse,
# `--check` treats a MISSING member of FILES as DRIFT — see the FILES warning above — so listing it
# would red-light every receiver that has not been pinned yet. That is the exact merge-freeze this
# change exists to END.
#
# An ABSENT pin is legal, and means LEGACY MODE: compare against main, exactly as before. That is what
# makes this rollout incapable of freezing anyone. A receiver behaves EXACTLY as it does today until
# the propagate bot's next sync PR writes its pin — at which point it stops racing. No receiver
# gate.yml change, no flag day, no per-repo adoption item, no coordinated rollout.
PIN_REL=".config/fsgg-build-config.sha"
# The git checkout THIS SCRIPT lives in — i.e. FS-GG/.github. That is where a pinned commit's
# dist/dotnet/ has to be read from, and it is NOT $TARGET (the receiver never carries .github history).
GITROOT="$(git -C "$(dirname "$SELF")" rev-parse --show-toplevel 2>/dev/null || true)"
# read_pin <repo> -> prints the pinned SHA, or nothing.
# The file is self-describing for humans (see write_pin), so take the first bare 40-hex line and
# ignore comments/blanks. A malformed pin prints nothing and lands in legacy mode — fail-open, which
# is the whole safety posture here: a pin we cannot read must never be able to FAIL a receiver's PR.
read_pin() {
local f="$1/$PIN_REL"
[[ -f "$f" ]] || return 0
grep -oE '^[0-9a-f]{40}$' "$f" 2>/dev/null | head -n1 || true
}
# write_pin <repo> <sha> — record which .github commit these managed files were taken from.
write_pin() {
local repo="$1" sha="$2"
mkdir -p "$(dirname "$repo/$PIN_REL")"
cat > "$repo/$PIN_REL" <<EOF
# The FS-GG/.github commit whose dist/dotnet/ this repo's managed build files were synced from.
#
# The shared-build-config drift check compares those files against THIS commit — not against
# .github@main — so the check's answer depends only on this repo's own tree, and a change landing in
# .github can never red-light an open PR here (.github#592). Being behind main is not a failure: the
# build-config-propagate bot (.github#626) opens a rolling, auto-merging PR that bumps these files and
# this pin together.
#
# Written by scripts/sync-build-config.sh. Do not hand-edit: editing the pin without re-syncing the
# files is how you would tell the gate to check your work against the wrong baseline.
$sha
EOF
echo "pinned: $PIN_REL -> $sha"
}
# source_sha -> prints the .github commit to record in a receiver's pin, or nothing.
#
# The propagate workflow passes the exact commit it is distributing; a human running the script from a
# checkout gets that checkout's HEAD. If neither is available (no git, a tarball, a detached source
# tree) we print nothing and simply do not write a pin — apply still syncs the files, and the receiver
# stays in legacy mode. Refusing to sync over a missing pin would be a self-inflicted outage.
#
# A DIRTY dist/dotnet/ YIELDS NO PIN, and this is the subtle one. `apply` copies the WORKING TREE, but
# a pin names a COMMIT — so syncing from a checkout with uncommitted changes under dist/dotnet/ would
# hand the receiver files that match no commit, stamped with a pin claiming they match HEAD. --check
# would then resolve HEAD, diff it against files that were never in it, and report DRIFT — accusing an
# innocent receiver of a hand-edit that the operator made HERE. The pin is a factual claim about
# provenance; when we cannot make it truthfully, we make none.
source_sha() {
if [[ -n "${FSGG_BUILD_CONFIG_SHA:-}" ]]; then
printf '%s' "$FSGG_BUILD_CONFIG_SHA"; return 0
fi
[[ -n "$GITROOT" ]] || return 0
git -C "$GITROOT" diff --quiet HEAD -- dist/dotnet 2>/dev/null || return 0
git -C "$GITROOT" rev-parse HEAD 2>/dev/null || true
}
# have_commit <sha> — is that commit present in this .github checkout?
have_commit() { [[ -n "$GITROOT" ]] && git -C "$GITROOT" cat-file -e "${1}^{commit}" 2>/dev/null; }
# materialize_pin <sha> <destdir> — write dist/dotnet/<FILES> AS OF <sha> into <destdir>.
#
# Every receiver's CI checks .github out at depth 1, so the pinned commit is usually NOT in the local
# object store and must be fetched. GitHub serves a bare SHA on request, and a depth-1 fetch of one
# commit brings its trees and blobs with it — which is all we read.
#
# Returns non-zero if the commit cannot be obtained, or if ANY managed file is unreadable at it (e.g. a
# pin predating a file's addition to dist/dotnet/). A PARTIALLY resolved baseline is not a baseline, so
# this is all-or-nothing: the caller falls back to main and says so, rather than silently checking half
# the files against one commit and half against another.
materialize_pin() {
local sha="$1" dest="$2" rel attempt
[[ -n "$GITROOT" ]] || return 1
if ! have_commit "$sha"; then
# Only worth a network round-trip if there is somewhere to fetch FROM. In the fixture (a bare
# `git init` with no remote) there is not, and `git fetch origin` would just fail slowly.
git -C "$GITROOT" remote get-url origin >/dev/null 2>&1 || return 1
# RETRY. This fetch is the one part of the check that is not a function of the receiver's tree, and
# a failure here costs more than a slow run: it drops us into the degraded path below. Three tries
# with a short backoff turns "GitHub blinked" from an outcome into a non-event. It cannot fix a pin
# that names a commit which does not exist — nothing can — and that case still exits non-zero after
# three quick, cheap failures.
for attempt in 1 2 3; do
git -C "$GITROOT" fetch --quiet --depth 1 origin "$sha" 2>/dev/null && break
[[ "$attempt" -eq 3 ]] && return 1
sleep $(( attempt * 2 ))
done
have_commit "$sha" || return 1
fi
for rel in "${FILES[@]}"; do
mkdir -p "$dest/$(dirname "$rel")"
git -C "$GITROOT" show "$sha:dist/dotnet/$rel" > "$dest/$rel" 2>/dev/null || return 1
done
}
# XML well-formedness guard (.github#29). The drift check compares files byte-for-byte,
# so a malformed-but-verbatim source (e.g. a `--` inside an XML comment) passes --check
# yet fails every adopter's `dotnet restore`/`pack` with MSB4024. Assert the source .props
# are loadable XML BEFORE we distribute or pass-check them, so the source of truth can't
# ship invalid XML again. Prefer xmllint; fall back to python3; warn (don't block) if neither.
assert_source_xml_wellformed() {
local validator=""
if command -v xmllint >/dev/null 2>&1; then
validator="xmllint"
elif command -v python3 >/dev/null 2>&1; then
validator="python3"
else
echo "WARN: no xmllint or python3 found; skipping XML well-formedness check of source .props" >&2
return 0
fi
local f bad=0
for f in "$SRC"/*.props; do
[[ -f "$f" ]] || continue
case "$validator" in
xmllint) xmllint --noout "$f" || bad=1 ;;
python3) python3 -c 'import sys,xml.dom.minidom; xml.dom.minidom.parse(sys.argv[1])' "$f" || bad=1 ;;
esac
done
if [[ "$bad" -ne 0 ]]; then
echo "Source .props in $SRC are not well-formed XML; refusing to distribute. Fix the source of truth first." >&2
exit 1
fi
}
assert_source_xml_wellformed
mode="apply"
case "${1:-}" in
--check) mode="check"; shift ;;
--adopt) mode="adopt"; shift ;;
-*) echo "unknown flag: $1" >&2; exit 2 ;;
esac
TARGET="${1:-}"
if [[ -z "$TARGET" ]]; then
echo "usage: $(basename "$0") [--check|--adopt] <path-to-consumer-repo>" >&2
exit 2
fi
TARGET="$(cd "$TARGET" && pwd)"
# ---- Which baseline does --check measure this receiver against? (.github#592) --------------------
#
# BASE is the directory of canonical files to compare with, and BASE_DESC names the commit it came
# from so a red gate can state its own yardstick. Two modes, and the difference is the whole item:
#
# PINNED — BASE is dist/dotnet/ AS OF the receiver's pin. The verdict then depends only on the
# receiver's own tree, so a change landing in .github@main cannot touch it. No race.
# LEGACY — no pin: BASE is dist/dotnet/ as checked out (main). Exactly today's behaviour, race and
# all. This is the state every receiver is in until the propagate bot pins it, and keeping
# it working is what makes this rollout unable to freeze anybody.
#
# apply/adopt ALWAYS distribute from $SRC — a sync writes what .github ships NOW. Only --check reads
# the pin, so BASE is deliberately consulted in the check arm alone.
BASE="$SRC"
BASE_DESC="dist/dotnet/ as checked out here"
PIN=""
PINDIR=""
BEHIND=""
PIN_UNRESOLVED=""
cleanup() { [[ -z "$PINDIR" ]] || rm -rf "$PINDIR"; }
trap cleanup EXIT
if [[ "$mode" == "check" ]]; then
PIN="$(read_pin "$TARGET")"
if [[ -z "$PIN" ]]; then
echo "NOTE: no $PIN_REL here, so this repo is checked against .github as currently checked out."
echo " That answer depends on .github's main AT CI TIME, so an unrelated change there can red-"
echo " light this repo's open PRs (.github#592). It is not a fault in this repo and there is"
echo " nothing to do: build-config-propagate (#626) writes the pin on its next sync PR, and"
echo " this stops. Until then, legacy behaviour is preserved exactly."
else
PINDIR="$(mktemp -d "${TMPDIR:-/tmp}/fsgg-build-config-pin.XXXXXX")"
if materialize_pin "$PIN" "$PINDIR"; then
BASE="$PINDIR"
BASE_DESC="FS-GG/.github@${PIN:0:12} dist/dotnet/ (this repo's pin)"
# Is the receiver behind? Answer it ONLY with real history. Every receiver's CI clones .github at
# depth 1 and materialize_pin then fetches the pinned commit as a SECOND, disconnected shallow
# root — so ancestry and commit counts are literally unanswerable there. Asking anyway would
# print a confident lie ("your pin is not an ancestor of main!") about every correctly-pinned
# repo in the org, on every run. A gate that cries wolf by construction is one nobody reads.
head_sha="$(git -C "$GITROOT" rev-parse HEAD 2>/dev/null || true)"
shallow="$(git -C "$GITROOT" rev-parse --is-shallow-repository 2>/dev/null || echo true)"
if [[ -n "$head_sha" && "$PIN" != "$head_sha" ]]; then
BEHIND="the pin is not the .github commit this check is running from (${head_sha:0:12})"
if [[ "$shallow" == "false" ]]; then
n="$(git -C "$GITROOT" rev-list --count "$PIN..$head_sha" -- dist/dotnet scripts/sync-build-config.sh 2>/dev/null || true)"
[[ -z "$n" || "$n" == "0" ]] || BEHIND="$n build-config change(s) behind ${head_sha:0:12}"
fi
fi
else
# FAIL OPEN, AND MEAN IT.
#
# We know this repo INTENDS to be pinned — it committed a pin — but we cannot read the baseline it
# names. So we cannot tell "behind" from "hand-edited", and the two have opposite verdicts. The
# only honest thing is to REFUSE TO JUDGE: warn loudly, and do not fail.
#
# This branch used to just fall back to comparing against main, and that was WRONG in the precise
# way this whole item is about. A receiver that is merely BEHIND does not match main — so the
# fallback RED-LIT it. One transient `git fetch` blip in CI was therefore enough to re-create the
# merge freeze (#499/#536) on an innocent PR that changed nothing, on a REQUIRED check, while the
# comment above it claimed to be failing open. A gate that reddens people for a network hiccup is
# the defect wearing a new hat.
#
# The residual, stated plainly: a receiver that BOTH corrupts its pin AND hand-edits a managed file
# goes green. That is accepted. It requires a deliberate, conspicuous edit to a file whose only
# content is a bot-written SHA — visible in any review — and the propagate bot rewrites both halves
# on its next run, after which the check bites again. Weigh that against the alternative, which is
# freezing four repos because GitHub's git endpoint blinked.
PIN_UNRESOLVED=1
echo "WARN: $PIN_REL pins FS-GG/.github@${PIN:0:12}, which could not be resolved (unknown commit," >&2
echo " unfetchable after 3 tries, or a managed file did not exist at it)." >&2
echo " This check CANNOT be evaluated: without that baseline there is no way to tell a repo" >&2
echo " that is merely BEHIND from one with a HAND-EDITED file, and those have opposite" >&2
echo " verdicts. Refusing to guess — reporting ADVISORY and passing (.github#592)." >&2
echo " Re-sync to rewrite the pin; the propagate bot will also do it on its next run." >&2
# Drop OUR scratch dir by name. Never glob the tmp prefix: a parallel run of this script owns a
# sibling directory matching the same pattern, and a bulk rm would delete a baseline out from
# under it mid-diff.
rm -rf "$PINDIR"; PINDIR=""
fi
fi
fi
drift=0
for rel in "${FILES[@]}"; do
# apply/adopt DISTRIBUTE from $SRC — a sync writes what .github ships now, always.
# check COMPARES against $BASE — the receiver's pinned commit when it has one, else $SRC.
# These are different questions and they must not collapse back into one variable: diffing against
# $SRC in the check arm is precisely the race #592 removed.
src="$SRC/$rel"
base="$BASE/$rel"
dst="$TARGET/$rel"
case "$mode" in
check)
if [[ ! -f "$dst" ]]; then
echo "DRIFT (missing): $rel"; drift=1
elif ! diff -q "$base" "$dst" >/dev/null 2>&1; then
echo "DRIFT (differs): $rel"; drift=1
else
echo "ok: $rel"
fi
;;
apply|adopt)
mkdir -p "$(dirname "$dst")"
# First-time adoption: a pre-existing, hand-authored .props is renamed to *.local.props
# (the canonical file imports it), so repo-specific settings survive the takeover.
if [[ "$rel" == *.props && -f "$dst" ]] && ! grep -q "$MARKER" "$dst"; then
local_dst="${dst%.props}.local.props"
if [[ "$mode" == "adopt" ]]; then
if [[ -e "$local_dst" ]]; then
# Both a hand-authored $rel and a pre-existing *.local.props exist. We cannot move the
# hand-authored file onto *.local.props (that target is taken), and falling through to the
# canonical `cp` below would silently destroy the hand-authored file's settings. Refuse this
# file — fail-closed, exactly like apply-mode's refusal below (.github#126, review M1). The
# operator merges the wanted settings from $rel into the existing *.local.props, deletes
# $rel, then re-runs --adopt.
echo "REFUSING to adopt $rel: a hand-authored $rel and $(basename "$local_dst") both exist." >&2
echo " Adopting would overwrite the hand-authored $rel, but its content cannot be moved to" >&2
echo " $(basename "$local_dst") (already present). Merge the settings you want from $rel into" >&2
echo " $(basename "$local_dst"), then delete $rel and re-run --adopt." >&2
drift=1
continue
else
mv "$dst" "$local_dst"
echo "adopted: $rel -> $(basename "$local_dst")"
fi
else
echo "REFUSING to overwrite hand-authored $rel (no '$MARKER' marker)." >&2
echo " Run with --adopt once to move it to $(basename "$local_dst"), or remove it first." >&2
drift=1
continue
fi
fi
# Every managed file is a `.props` now (#1077 moved the one non-.props file, the tool manifest,
# to the coordination-kit). The hand-authored-file safety above (marker + *.local.props import)
# covers all of them; there is no longer a manifest arm here.
cp "$src" "$dst"
echo "wrote: $rel"
;;
esac
done
# ---- Record the provenance pin (.github#592) -----------------------------------------------------
#
# ONLY on a fully clean sync. `drift` is non-zero here when a file was REFUSED (a hand-authored .props,
# or an adopt whose *.local target was taken), and in that case the receiver's managed files are NOT
# the content of any single .github commit. A pin written over that state would be a false claim, and
# --check would then measure a half-synced repo against a baseline it never matched — turning a loud,
# actionable refusal into a silent, permanent red. So the pin is earned by a complete sync, or not
# written at all.
#
# A missing source_sha (no git, a tarball, an exported tree) is NOT fatal: the files still sync and the
# receiver stays in legacy mode. Refusing to distribute over an unknowable pin would be an outage we
# inflicted on ourselves for a provenance note.
if [[ "$mode" != "check" && "$drift" -eq 0 ]]; then
pin_sha="$(source_sha)"
if [[ "$pin_sha" =~ ^[0-9a-f]{40}$ ]]; then
write_pin "$TARGET" "$pin_sha"
else
echo "WARN: no $PIN_REL written — the FS-GG/.github commit being distributed is not knowable here." >&2
echo " Either this is not a git checkout, or dist/dotnet/ has UNCOMMITTED changes (so the files just" >&2
echo " written match no commit, and any pin would be a false claim). The sync itself is complete and" >&2
echo " correct; the receiver simply stays on the pre-#592 compare-against-main drift check." >&2
echo " Commit dist/dotnet/, or pass FSGG_BUILD_CONFIG_SHA=<sha>, to pin it." >&2
fi
fi
# UNEVALUATABLE, NOT FAILED (.github#592). The pin named a baseline we could not read, so the DRIFT
# lines above were produced against main — which a merely-BEHIND receiver never matches. Reporting them
# as a failure would red-light an innocent PR on a required check, which is the freeze this item exists
# to end. Print them as ADVISORY and pass; the pin, once resolvable again, gives a real verdict.
if [[ "$mode" == "check" && -n "$PIN_UNRESOLVED" ]]; then
if [[ "$drift" -ne 0 ]]; then
echo ""
echo "ADVISORY (not a failure): the file(s) above differ from .github as currently checked out."
echo " Because $PIN_REL could not be resolved, this is NOT evidence of a hand-edit — a repo that is"
echo " simply BEHIND the org baseline differs from it in exactly this way, and that is green"
echo " (.github#592). Re-sync to rewrite the pin and get a real verdict."
fi
echo "Done ($mode; pin unresolved — check reported ADVISORY, see WARN above)."
exit 0
fi
if [[ "$mode" == "check" && "$drift" -ne 0 ]]; then
# Name a runnable command, not a relative path that exists only in .github (#633). Both forms are
# printed because there are two readers of this failure and they are standing in different places:
# someone with a .github checkout (the resolved path works), and someone looking at a red gate in a
# receiver repo, who has no .github checkout at all (the clone form works).
#
# %q, not bare interpolation: these lines exist to be PASTED. An unquoted path with a space in it
# would paste as two arguments and run the sync against the wrong directory. %q leaves an ordinary
# path untouched, so this costs nothing in the normal case.
#
# The clone form clones into a FRESH mktemp dir rather than a fixed /tmp/... path, because a fixed
# one makes the command fail on its SECOND use ("destination path already exists and is not an empty
# directory") — for the reader with two drifted repos, or who simply runs it twice. A remediation
# that only works once is the same defect as one that never works.
{
echo ""
echo "Build-config drift detected: the file(s) marked DRIFT above differ from $BASE_DESC."
if [[ -n "$PIN" && "$BASE" != "$SRC" ]]; then
# The pinned reading of a red gate, and it is a genuinely different accusation from the old one.
# Pre-#592 a red here often meant "somebody else changed .github and you have not caught up yet" —
# not your fault, not fixable from your branch. It cannot mean that any more: the baseline IS the
# commit this repo says its files came from. So a difference is a LOCAL EDIT, every time, and the
# author reading this can fix it from the branch they are already on. Say so, or they will go
# looking for the upstream change that is no longer there.
echo ""
echo "This repo is PINNED to FS-GG/.github@${PIN:0:12}, and the files above do not match what that"
echo "commit distributes. So this is NOT 'you are behind main' — being behind main is green now"
echo "(.github#592). A managed file has been HAND-EDITED in this repo. Re-sync to restore it, and"
echo "put any repo-specific settings in Directory.Build.local.props / Directory.Packages.local.props,"
echo "which the sync never touches."
fi
echo ""
echo "This script lives in FS-GG/.github and is NOT checked into the repo being checked, so there is"
echo "no 'scripts/sync-build-config.sh' in $TARGET. Re-sync with whichever fits where you are:"
echo ""
echo " # ...from a checkout of FS-GG/.github (this script, resolved):"
echo " $(printf '%q' "$SELF") $(printf '%q' "$TARGET")"
echo ""
echo " # ...from the root of the repo that just went red, with no .github checkout to hand:"
echo " d=\$(mktemp -d) && git clone --depth 1 https://github.com/FS-GG/.github.git \"\$d/org\" \\"
echo " && \"\$d/org/scripts/sync-build-config.sh\" ."
echo ""
echo "Then commit the updated file(s). Do not hand-edit them — they are overwritten on every re-sync."
echo ""
echo "If that re-sync REFUSES a file, this repo is ADOPTING one it hand-authored before the org"
echo "managed it. Re-run the same command once with --adopt, which moves your version aside to"
echo "*.local.props (still imported, so your settings survive) before writing the canonical file."
} >&2
exit 1
fi
if [[ "$mode" != "check" && "$drift" -ne 0 ]]; then
exit 1
fi
# GREEN, BUT BEHIND — and that is a NOTICE, not a failure (.github#592).
#
# This is the case the whole item turns on. The receiver's files faithfully match the commit it is
# pinned to; .github has simply moved on since. Nobody did anything wrong, nothing in this PR can or
# should fix it, and failing here would re-create the merge freeze the pin exists to end. The bot
# (#626) already has a rolling, auto-merging PR open to close the gap.
#
# Deliberately NOT bounded ("fail if more than N commits / N days behind"). A staleness bound would
# reintroduce a failure that depends on time and on .github's commit rate rather than on this repo's
# tree — which is the exact class of defect being removed. Currency is the bot's job to enforce, and
# this line's job is to make the gap visible while it does it.
if [[ "$mode" == "check" && -n "$BEHIND" ]]; then
echo ""
echo "NOTICE: build config is coherent with its pin, and BEHIND the org baseline ($BEHIND)."
echo " This is GREEN on purpose. Being behind is not a defect in this PR and is not yours to"
echo " fix here: build-config-propagate (.github#626) keeps a rolling, auto-merging sync PR"
echo " open in this repo that bumps the managed files and $PIN_REL together."
fi
echo "Done ($mode)."