2121 --core-sha <40 hex> [--plugins-sha <40 hex>] --instance <run url> \
2222 [--supersedes "sha256:… sha256:…"]
2323 → the event JSON, one line (the bytes that are signed are the bytes that are sent)
24+ build-host-announcement.py channels --declaration .github/build-hosts.json --version 3.1.13071
25+ → `channels=<the declared channels this set moves>` (GITHUB_OUTPUT line): its LINE's channel
26+ `latest<major>`, and `latest` for a set of the line it names (see THE LINES)
2427 build-host-announcement.py --self-test
2528
29+ 🚨 THE LINES — A CHANNEL NEVER CROSSES A MAJOR (policy `major-lines-run-separately`, platform
30+ Doc/Architecture/MajorLinesRunSeparately; the incident of core #8129). Each platform major is its own
31+ line with its own channel, `latest<major>` (`latest3`, `latest4`) — an OPEN vocabulary: a new major
32+ needs a declared channel here and on the record, no change to this script. The bare `latest` is the
33+ v3 line's COMPATIBILITY name (`LATEST_ALIAS_MAJOR`), kept while any reader still follows it (the
34+ standing `build-host-latest`, a leased host's `follow-set.py`). A set is handed to EVERY declared
35+ channel of its own major — `latest3` and `latest` for a 3.x set — as ONE event per channel: each
36+ event moves only its own channel's key on the record, so nothing is applied twice, and the receiver
37+ answers a re-send of the set a channel already runs with "nothing changed". A set is never handed to
38+ a channel of another major: `body` refuses it here, and the receiver (MeshWeaver.Plugins
39+ `BuildHostUpdates.Decide`) refuses it again.
40+
2641🚨 A NO-OP UNTIL DECLARED. `.github/build-hosts.json` says whether the platform's estate runs
2742standing build hosts at all (`enabled`) and which channels CD rolls (`channels`). While it says
2843`false` the CD job writes one summary line and sends nothing — and the RECEIVER guards the same
5469DIGEST = re .compile (r"^sha256:[0-9a-f]{64}$" )
5570SHA = re .compile (r"^[0-9a-f]{40}$" )
5671CHANNELS = ("latest" , "stable" )
72+ # A major line's channel: `latest<major>` (policy `major-lines-run-separately`). Open — any major.
73+ LINE_CHANNEL = re .compile (r"^latest([1-9][0-9]{0,3})$" )
74+ # The line the compatibility name `latest` belongs to: the v3 line, which every reader followed before
75+ # the lines split. MeshWeaver.Plugins `BuildHostUpdates.CompatibilityAliasMajor` names the same major.
76+ LATEST_ALIAS_MAJOR = 3
5777# Which channel each CD trigger may roll. `latest` follows the set CD ARMS (any trigger that arms);
5878# `stable` — until core's stable promoter exists — is the main-passed ceiling, handed on by the
5979# DAILY `schedule` of build-host-stable.yml only (§2: "by the daily full run, to the main-passed
6585MAX_SUPERSEDED = 64
6686
6787
88+ def is_channel (channel : object ) -> bool :
89+ """A channel this lane may name: `latest`, `stable`, or a line's `latest<major>`. Pure."""
90+ return isinstance (channel , str ) and (channel in CHANNELS or LINE_CHANNEL .match (channel ) is not None )
91+
92+
93+ def channel_major (channel : str ) -> int | None :
94+ """The major a channel's NAME commits it to: `latest<N>` → N, `latest` → LATEST_ALIAS_MAJOR, any other
95+ (`stable`) → None — no line, no major guard. Pure."""
96+ if channel == "latest" :
97+ return LATEST_ALIAS_MAJOR
98+ m = LINE_CHANNEL .match (channel or "" )
99+ return int (m .group (1 )) if m else None
100+
101+
102+ def set_major (version : str ) -> int | None :
103+ """The MAJOR of a set name (`3.1.13071` → 3, `3.0.0-ci.10052` → 3, `4.0.13200` → 4); None for no set name. Pure."""
104+ return int (version .split ("." , 1 )[0 ]) if SET_NAME .match (version or "" ) else None
105+
106+
68107def load_declaration (path : Path ) -> dict :
69- d = json .loads (path .read_text ())
108+ return load_declaration_doc (json .loads (path .read_text ()), path )
109+
110+
111+ def load_declaration_doc (d : object , path : object = ".github/build-hosts.json" ) -> dict :
70112 if not isinstance (d , dict ) or type (d .get ("enabled" )) is not bool :
71113 raise ValueError (f"{ path } : `enabled` must be true or false" )
72114 ch = d .get ("channels" )
73- if not isinstance (ch , list ) or not all (isinstance ( c , str ) and c in CHANNELS for c in ch ):
74- raise ValueError (f"{ path } : `channels` must be a list drawn from { list (CHANNELS )} " )
115+ if not isinstance (ch , list ) or not all (is_channel ( c ) for c in ch ) or len ( set ( ch )) != len ( ch ):
116+ raise ValueError (f"{ path } : `channels` must be a list of distinct names drawn from { list (CHANNELS )} or latest<major> " )
75117 return d
76118
77119
120+ def channels_for (declaration : dict , version : str ) -> list [str ]:
121+ """The declared channels CD's newest armed set `version` moves, in send order: its line's own
122+ `latest<major>` first, then the compatibility `latest` when the set is of the line `latest` names.
123+ `stable` is never one (build-host-stable.yml's daily lane rolls it). Empty when the estate does not
124+ run build hosts or declares no channel of the set's major. Raises ValueError for no set name. Pure."""
125+ major = set_major (version )
126+ if major is None :
127+ raise ValueError (f"version { version !r} is not a platform set name" )
128+ if not declaration ["enabled" ]:
129+ return []
130+ line = f"latest{ major } "
131+ ordered = [line , "latest" ]
132+ return [c for c in ordered if c in declaration ["channels" ] and channel_major (c ) == major ]
133+
134+
78135def decide (declaration : dict , control : dict , channel : str , trigger : str = "" ) -> tuple [bool , str , str ]:
79136 """(send?, deployment, why). Pure."""
80137 dep = str (control .get ("deployment" ) or "" ).strip ()
81138 if not re .fullmatch (r"[A-Za-z0-9][A-Za-z0-9._/-]*" , dep ):
82139 raise ValueError (f"control-instance.json names no plain `deployment` ({ dep !r} ) — the operations record "
83140 f"the build hosts live on" )
84- if channel not in CD_CHANNELS :
85- raise ValueError (f"channel { channel !r} is not a channel CD rolls ({ list (CD_CHANNELS )} )" )
86- only_on = CD_CHANNELS [ channel ]
141+ if channel not in CD_CHANNELS and not LINE_CHANNEL . match ( channel or "" ) :
142+ raise ValueError (f"channel { channel !r} is not a channel CD rolls ({ list (CD_CHANNELS )} or latest<major> )" )
143+ only_on = CD_CHANNELS . get ( channel )
87144 if only_on is not None and trigger != only_on :
88145 raise ValueError (f"channel { channel !r} is rolled only by the { only_on !r} trigger (this is { trigger or 'no trigger' !r} )" )
89146 if not declaration ["enabled" ]:
@@ -99,10 +156,13 @@ def body(*, deployment: str, channel: str, version: str, tester_image: str, test
99156 plugins_sha : str , instance : str , detected_at : str , supersedes : list [str ] | None = None ) -> dict :
100157 """The signed event. Pure; every field validated — a fact naming something malformed is worse
101158 than no fact."""
102- if channel not in CHANNELS :
103- raise ValueError (f"channel { channel !r} is not one of { list (CHANNELS )} " )
159+ if not is_channel ( channel ) :
160+ raise ValueError (f"channel { channel !r} is not one of { list (CHANNELS )} or latest<major> " )
104161 if not SET_NAME .match (version or "" ):
105162 raise ValueError (f"version { version !r} is not a platform set name" )
163+ if (line := channel_major (channel )) is not None and set_major (version ) != line :
164+ raise ValueError (f"{ version } is not a { line } .x set, and build-{ channel } is the { line } .x line's channel — "
165+ "a channel never crosses a major; refusing to sign it" )
106166 for name , d in (("tester" , tester_digest ), ("portal" , portal_digest ), ("runner" , runner_digest )):
107167 if not DIGEST .match (d or "" ):
108168 raise ValueError (f"{ name } digest { d !r} is not sha256:<64 hex>" )
@@ -163,6 +223,9 @@ def main(argv: list[str]) -> int:
163223 d .add_argument ("--control-instance" , type = Path , default = Path (".github/control-instance.json" ))
164224 d .add_argument ("--channel" , default = "latest" )
165225 d .add_argument ("--trigger" , default = "" )
226+ c = sub .add_parser ("channels" )
227+ c .add_argument ("--declaration" , type = Path , default = Path (".github/build-hosts.json" ))
228+ c .add_argument ("--version" , required = True )
166229 b = sub .add_parser ("body" )
167230 for k in ("deployment" , "channel" , "version" , "tester-image" , "tester-digest" , "portal-image" ,
168231 "portal-digest" , "runner-image" , "runner-digest" , "instance" ):
@@ -172,6 +235,9 @@ def main(argv: list[str]) -> int:
172235 b .add_argument ("--supersedes" , default = "" , help = "space-separated digests of the set's OLDER runners" )
173236 a = ap .parse_args (argv )
174237 try :
238+ if a .cmd == "channels" :
239+ print (f"channels={ ' ' .join (channels_for (load_declaration (a .declaration ), a .version ))} " )
240+ return 0
175241 if a .cmd == "decide" :
176242 send , dep , why = decide (load_declaration (a .declaration ),
177243 json .loads (a .control_instance .read_text ()), a .channel , a .trigger )
@@ -263,17 +329,63 @@ def check(name: str, cond: bool, detail: object = "") -> None:
263329 check (f"body/refuses { k } ={ v } " , False , "accepted" )
264330 except ValueError :
265331 pass
332+ # THE LINES (policy major-lines-run-separately; core #8129): a set moves every declared channel of its
333+ # own major, one event each, and never a channel of another major.
334+ lines = {"enabled" : True , "channels" : ["latest" , "latest3" , "latest4" ]}
335+ check ("lines/a 3.x set moves its line's channel first, then the compatibility latest" ,
336+ channels_for (lines , "3.1.13071" ) == ["latest3" , "latest" ], channels_for (lines , "3.1.13071" ))
337+ check ("lines/a 3.0.0-ci set is of the v3 line too" , channels_for (lines , "3.0.0-ci.10052" ) == ["latest3" , "latest" ])
338+ check ("lines/a 4.x set moves latest4 ALONE — never latest or latest3 (the incident)" ,
339+ channels_for (lines , "4.0.13200" ) == ["latest4" ], channels_for (lines , "4.0.13200" ))
340+ check ("lines/a set of a line nobody declared moves nothing" , channels_for (lines , "5.0.1" ) == [])
341+ check ("lines/today's declaration (latest only) moves latest for a 3.x set — as before" ,
342+ channels_for ({"enabled" : True , "channels" : ["latest" ]}, "3.1.13071" ) == ["latest" ])
343+ check ("lines/…and nothing for a 4.x set (latest is the v3 line's name)" ,
344+ channels_for ({"enabled" : True , "channels" : ["latest" ]}, "4.0.13200" ) == [])
345+ check ("lines/stable is never moved by the CD hand-over" , channels_for ({"enabled" : True , "channels" : ["latest3" , "stable" ]}, "3.1.13071" ) == ["latest3" ])
346+ check ("lines/off moves nothing" , channels_for (dict (lines , enabled = False ), "3.1.13071" ) == [])
347+ try :
348+ channels_for (lines , "latest" )
349+ check ("lines/a moving name is no set — refused" , False , "accepted" )
350+ except ValueError :
351+ pass
352+ check ("lines/the vocabulary" , channel_major ("latest" ) == 3 and channel_major ("latest3" ) == 3 and channel_major ("latest4" ) == 4
353+ and channel_major ("stable" ) is None and channel_major ("latest03" ) is None and channel_major ("latest-x" ) is None )
354+ send , _ , _ = decide ({"enabled" : True , "channels" : ["latest3" ]}, control , "latest3" )
355+ check ("decide/a declared line channel follows any arming trigger" , send is True )
356+ for v , ch in (("3.1.13071" , "latest3" ), ("3.1.13071" , "latest" ), ("4.0.13200" , "latest4" )):
357+ check (f"body/{ v } is signed for { ch } " , body (** dict (ok , version = v , channel = ch ))["channel" ] == ch )
358+ check ("body/the record fields name the line's channel" , "buildHosts.latest3.platformVersion" in body (** dict (ok , channel = "latest3" ))["recordFields" ])
359+ # NEGATIVE CONTROLS — each would have moved a v3 reader onto v4 (or the reverse) under a lane that compared nothing.
360+ for v , ch in (("4.0.13200" , "latest" ), ("4.0.13200" , "latest3" ), ("3.1.13071" , "latest4" )):
361+ try :
362+ body (** dict (ok , version = v , channel = ch ))
363+ check (f"body/refuses { v } on { ch } " , False , "accepted" )
364+ except ValueError as e :
365+ check (f"body/the refusal of { v } on { ch } is named" , "never crosses a major" in str (e ), str (e ))
366+ check ("body/stable takes any major (no line, no guard)" , body (** dict (ok , version = "4.0.13200" , channel = "stable" ))["channel" ] == "stable" )
367+ for bad in (["latest" , "latest" ], ["latest-x" ], ["latest0" ], "latest" ):
368+ try :
369+ load_declaration_doc ({"enabled" : True , "channels" : bad })
370+ check (f"declaration/refuses channels={ bad !r} " , False , "accepted" )
371+ except ValueError :
372+ pass
266373 shipped = Path (__file__ ).resolve ().parents [1 ] / "build-hosts.json"
267374 try :
268- load_declaration (shipped )
375+ shipped_decl = load_declaration (shipped )
376+ check ("the shipped declaration hands a 3.x set to latest (the compatibility name every current reader follows)" ,
377+ "latest" in channels_for (shipped_decl , "3.1.13071" ), channels_for (shipped_decl , "3.1.13071" ))
378+ check ("the shipped declaration never hands a 4.x set to latest or latest3" ,
379+ not {"latest" , "latest3" } & set (channels_for (shipped_decl , "4.0.13200" )), channels_for (shipped_decl , "4.0.13200" ))
269380 except (ValueError , OSError ) as e :
270381 check ("the shipped .github/build-hosts.json validates" , False , str (e ))
271382 if failures :
272383 for f in failures :
273384 print (f"SELF-TEST FAILED: { f } " )
274385 return 1
275386 print ("build-host-announcement self-test: all cases passed (no-op while off, channel declaration, "
276- "stable only on the daily schedule, record fields incl. the runner, the superseded runners, "
387+ "stable only on the daily schedule, the lines (a set moves only its own major's channels, one event each), "
388+ "record fields incl. the runner, the superseded runners, "
277389 "digest/repository/sha/version refusals, the shipped declaration)" )
278390 return 0
279391
0 commit comments