carlos / platform Public

changelog: keep nested sub-lists in release-note bullets

parseBullets ended a bullet at every blank line, so an indented list
after one was dropped: v0.26.0's instance key checks and dormant
switches never reached the page. A bullet now stays open across a blank
line long enough for an indented item to nest under it, and the items
are carried in an optional `items` field, so a reader that predates
them renders the bullet's md as before. A bullet's docs links now also
come from its items.

Flat notes marshal byte for byte as they did (TestParseNotesFlatUnchanged
pins the old parser's output); the regenerated changelog.json changes
only v0.26.0's two operator bullets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3f403359c5f711454d28b21a2a9aa08649375300 parent 729e850
4 files changed, +160 −3
  • docs/site/changelog.json +21 −1
  • internal/changelog/build.go +1 −1
  • internal/changelog/parse.go +23 −1
  • internal/changelog/parse_test.go +115 −0

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	349.847s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.234s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.181s
ok  	github.com/carlosframework/platform/internal/activator	230.751s
ok  	github.com/carlosframework/platform/internal/agentsock	0.017s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.431s
ok  	github.com/carlosframework/platform/internal/apperr	0.072s
ok  	github.com/carlosframework/platform/internal/applog	0.127s
ok  	github.com/carlosframework/platform/internal/awsinv	0.003s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.482s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.011s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.190s
ok  	github.com/carlosframework/platform/internal/changelog	0.286s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	365.233s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.797s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.002s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.176s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.003s
ok  	github.com/carlosframework/platform/internal/edge	25.942s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.007s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.126s
ok  	github.com/carlosframework/platform/internal/gate	1.658s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.014s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/host	75.309s
ok  	github.com/carlosframework/platform/internal/hostactors	0.352s
ok  	github.com/carlosframework/platform/internal/hostbox	1.738s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.483s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.005s
ok  	github.com/carlosframework/platform/internal/hostexec	0.319s
ok  	github.com/carlosframework/platform/internal/hostfence	0.035s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.390s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.008s
ok  	github.com/carlosframework/platform/internal/hostguard	0.145s
ok  	github.com/carlosframework/platform/internal/hostops	1.435s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.004s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.441s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.140s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	1.715s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.715s
ok  	github.com/carlosframework/platform/internal/instancefs	0.015s
ok  	github.com/carlosframework/platform/internal/instancekey	0.012s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.025s
ok  	github.com/carlosframework/platform/internal/layout	1.398s
ok  	github.com/carlosframework/platform/internal/lease	9.437s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.005s
ok  	github.com/carlosframework/platform/internal/ledger	0.011s
ok  	github.com/carlosframework/platform/internal/mail	0.169s
ok  	github.com/carlosframework/platform/internal/maintenance	0.076s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.518s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.071s
ok  	github.com/carlosframework/platform/internal/metrics	0.006s
ok  	github.com/carlosframework/platform/internal/moves	20.955s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.755s
ok  	github.com/carlosframework/platform/internal/objstore	0.178s
ok  	github.com/carlosframework/platform/internal/ops	0.183s
ok  	github.com/carlosframework/platform/internal/park	0.196s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	3.282s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.007s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	3.988s
ok  	github.com/carlosframework/platform/internal/relay	7.225s
ok  	github.com/carlosframework/platform/internal/release	19.647s
ok  	github.com/carlosframework/platform/internal/rollout	3.236s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.008s
ok  	github.com/carlosframework/platform/internal/s3compat	0.004s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.249s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	8.219s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.087s
ok  	github.com/carlosframework/platform/internal/status	0.038s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.125s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.553s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.023s
=== RUN   TestT4StorageProbeMinio
2026/10/13 11:16:40 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE12A4B7D8F3B3, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.024s
minio contract: PASS
diff --git a/docs/site/changelog.json b/docs/site/changelog.json
index 42055410b..b57315d9d 100644
--- a/docs/site/changelog.json
+++ b/docs/site/changelog.json
@@ -164,6 +164,13 @@
},
{
"md": "**Before any box rolls, run the instance key checks.** This release writes a `CARLOS_INSTANCE_KEY` into each instance's `<host>.env` the first time it starts, and starts a replica only with its home's key.",
+ "items": [
+ "On every box, `find <data> -maxdepth 1 -name '*.env' -type l` must print nothing. A `<host>.env` that is a symlink now fails that instance's start. Stop the instance, replace the link with the real file, and treat the token in it as leaked.",
+ "On every box with a replica (`sqlite3 <data>/registry.db \"select host from routes where replica=1\"`), compare each replica's key with its home's. A key is the last `CARLOS_INSTANCE_KEY` line of `<host>.env`, or of `<host>.config.env` when `<host>.env` has none, in the data directory for a flat instance or the instance's own directory for an isolated one. Where the two match, add `<replica-host>=<sha256 of the key>` to `/etc/carlos/replica-home-keys` on the replica's box before it rolls. Write the whole list to a temporary file in `/etc/carlos`, make it root:carlos 0640, and rename it into place. Where they differ, the replica is refused after the roll. Tell its owner first.",
+ "A replica with no key keeps starting where it has a control socket, but its legacy sidecar and any worker row of its own stop at the roll. List them on each box with a replica, and tell their owners.",
+ "Check the agent can read the list: `sudo -u <agent user> test -r /etc/carlos/replica-home-keys && echo ok` must print `ok`.",
+ "After the roll, `journalctl -u carlos-edge --since '<roll time>' | grep 'agent: instance keys:'` must show `0 failed`."
+ ],
"branches": [
"instance-key-provision",
"instance-key-build"
@@ -178,7 +185,20 @@
]
},
{
- "md": "**Every dormant switch stays off.** Leave each of these unset, and run none of the commands named, until its own rollout is approved."
+ "md": "**Every dormant switch stays off.** Leave each of these unset, and run none of the commands named, until its own rollout is approved.",
+ "items": [
+ "`CARLOS_SWAP` on every box. Two builds at once still has open items, and only the exact word `overlap` arms it.",
+ "`CARLOS_MOVES_PHASE1` on the console and every box. Don't publish a moves protocol floor (`control/moves-protocol.json`) either.",
+ "`CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS` and `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS` on the console and every box, and `CARLOS_CONSOLE_ID` on the console. Don't run `carlos system guard-arm` or `carlos system journal-migrate`.",
+ "`CARLOS_REGION_HA` on every box.",
+ "`CONSOLE_APP_REGIONS` and `CARLOS_TEMPORARY_APPS` on the console.",
+ "`CARLOS_REQUIRE_POINTER_DIGEST` on every box. Don't run `carlos channels digests on` yet."
+ ],
+ "docs": [
+ "/docs/cli#carlos-system",
+ "/docs/cli#carlos-system-journal-migrate",
+ "/docs/cli#carlos-channels-digests"
+ ]
},
{
"md": "**Host claims: every roster reader first.** Binaries older than this release reject a signed roster that names a home region. So before anyone publishes the home designation, every reader of `control/host-actors.json` must run this release: every console, every box's agent and root services, the update and rollback tools, and any offline or recovery binary. The designation is write-once, and this roll doesn't publish it.",
diff --git a/internal/changelog/build.go b/internal/changelog/build.go
index 0416778c8..ac9602b04 100644
--- a/internal/changelog/build.go
+++ b/internal/changelog/build.go
@@ -222,7 +222,7 @@ func (b *builder) release(t Tag, earlier []Release) (*Release, error) {
for si := range notesSections(r.Notes) {
for bi := range r.Notes.Sections[si].Bullets {
bl := &r.Notes.Sections[si].Bullets[bi]
- bl.Docs = capped(CLIDocLinks(bl.MD, b.cliHeadings))
+ bl.Docs = capped(CLIDocLinks(strings.Join(append([]string{bl.MD}, bl.Items...), "\n"), b.cliHeadings))
}
}
}
diff --git a/internal/changelog/parse.go b/internal/changelog/parse.go
index c6141647e..f87069053 100644
--- a/internal/changelog/parse.go
+++ b/internal/changelog/parse.go
@@ -66,7 +66,10 @@ type NoteSection struct {
}
type Bullet struct {
- MD string `json:"md"`
+ MD string `json:"md"`
+ // Items is the bullet's nested list, one markdown string per item, in
+ // order. It is optional: a reader that predates it renders MD alone.
+ Items []string `json:"items,omitempty"`
Lead string `json:"-"`
Branches []string `json:"branches,omitempty"`
Docs []string `json:"docs,omitempty"`
@@ -77,6 +80,7 @@ var (
sectionRe = regexp.MustCompile(`^\*\*([^*]+)\*\*\s*$`)
leadRe = regexp.MustCompile(`^\*\*(.+?)\*\*`)
headingRe = regexp.MustCompile(`^(#{1,6})(\s)`)
+ nestedItemRe = regexp.MustCompile(`^(?: +|\t+)[-*+]\s+(.*)$`)
)
// ParseNotes reads a release body. The boilerplate every release repeats
@@ -110,12 +114,21 @@ func parseBullets(lines []string) *Notes {
n := &Notes{Form: "bullets"}
cur := &NoteSection{}
var b *Bullet
+ // gap is a blank line since b's last line. The bullet stays open
+ // across it, so an indented list after the blank still nests under it;
+ // any other line after a gap closes the bullet, as a blank line always
+ // did. inItem is whether b's last line belongs to its last nested item.
+ gap, inItem := false, false
flush := func() {
if b != nil {
b.MD = strings.TrimSpace(b.MD)
+ for i := range b.Items {
+ b.Items[i] = strings.TrimSpace(b.Items[i])
+ }
cur.Bullets = append(cur.Bullets, *b)
b = nil
}
+ gap, inItem = false, false
}
for _, l := range lines {
t := strings.TrimSpace(l)
@@ -129,6 +142,9 @@ func parseBullets(lines []string) *Notes {
if m := leadRe.FindStringSubmatch(md); m != nil {
b.Lead = m[1]
}
+ case b != nil && nestedItemRe.MatchString(l):
+ b.Items = append(b.Items, nestedItemRe.FindStringSubmatch(l)[1])
+ gap, inItem = false, true
case sectionRe.MatchString(t):
flush()
if len(cur.Bullets) > 0 || cur.Title != "" {
@@ -136,7 +152,13 @@ func parseBullets(lines []string) *Notes {
}
cur = &NoteSection{Title: sectionRe.FindStringSubmatch(t)[1]}
case t == "":
+ if b != nil {
+ gap = true
+ }
+ case b != nil && gap:
flush()
+ case b != nil && inItem:
+ b.Items[len(b.Items)-1] += "\n" + t
case b != nil:
b.MD += "\n" + t
}
diff --git a/internal/changelog/parse_test.go b/internal/changelog/parse_test.go
index 44c6da1db..61e528cd5 100644
--- a/internal/changelog/parse_test.go
+++ b/internal/changelog/parse_test.go
@@ -1,6 +1,7 @@
package changelog
import (
+ "encoding/json"
"reflect"
"strings"
"testing"
@@ -126,3 +127,117 @@ func TestCLIDocLinks(t *testing.T) {
t.Fatalf("CLIDocLinks = %v, want %v", got, want)
}
}
+
+// flatGolden is ParseNotes(bulletNotes) marshalled by the parser before
+// nested items existed. Notes without a nested list must keep it byte for
+// byte, so a reader that predates items sees no change.
+const flatGolden = "{\"form\":\"bullets\",\"sections\":[{\"title\":\"\",\"bullets\":[{\"md\":\"**Canaries have a one-label address, and can be created again.** A canary's host is now `\\u003cname\\u003e-\\u003capp\\u003e`. You need this release's CLI.\"},{\"md\":\"**One address, one app.** `carlos instances create` and `carlos canary` are refused when another app holds the host.\\nIt continues here.\"}]},{\"title\":\"For operators\",\"bullets\":[{\"md\":\"**Check every box's secrets key before rolling the boxes.** A box now takes an exec instance only if it can open the config.\"}]}]}"
+
+func TestParseNotesFlatUnchanged(t *testing.T) {
+ got, err := json.Marshal(ParseNotes(bulletNotes))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != flatGolden {
+ t.Fatalf("flat notes changed:\n got %s\nwant %s", got, flatGolden)
+ }
+}
+
+// nestedNotes is the v0.26.0 operator bullets that carry a sub-list, as
+// published, with a flat bullet between and after them.
+const nestedNotes = "carlos v0.26.0 (platform 09c5a4a5, 2026-10-13)\n" +
+ "\n" +
+ "**For operators**\n" +
+ "\n" +
+ "- **Before any box rolls, run the instance key checks.** This release writes a `CARLOS_INSTANCE_KEY` into each instance's `<host>.env` the first time it starts, and starts a replica only with its home's key.\n" +
+ "\n" +
+ " - On every box, `find <data> -maxdepth 1 -name '*.env' -type l` must print nothing. A `<host>.env` that is a symlink now fails that instance's start. Stop the instance, replace the link with the real file, and treat the token in it as leaked.\n" +
+ " - On every box with a replica (`sqlite3 <data>/registry.db \"select host from routes where replica=1\"`), compare each replica's key with its home's. A key is the last `CARLOS_INSTANCE_KEY` line of `<host>.env`, or of `<host>.config.env` when `<host>.env` has none, in the data directory for a flat instance or the instance's own directory for an isolated one. Where the two match, add `<replica-host>=<sha256 of the key>` to `/etc/carlos/replica-home-keys` on the replica's box before it rolls. Write the whole list to a temporary file in `/etc/carlos`, make it root:carlos 0640, and rename it into place. Where they differ, the replica is refused after the roll. Tell its owner first.\n" +
+ " - A replica with no key keeps starting where it has a control socket, but its legacy sidecar and any worker row of its own stop at the roll. List them on each box with a replica, and tell their owners.\n" +
+ " - Check the agent can read the list: `sudo -u <agent user> test -r /etc/carlos/replica-home-keys && echo ok` must print `ok`.\n" +
+ " - After the roll, `journalctl -u carlos-edge --since '<roll time>' | grep 'agent: instance keys:'` must show `0 failed`.\n" +
+ "\n" +
+ "- **Root ignores AWS profile, endpoint and trust-store settings.** A box's root processes now ignore `AWS_PROFILE`, `AWS_DEFAULT_PROFILE`, `AWS_CA_BUNDLE`, the `AWS_ENDPOINT_URL` variables, `SSL_CERT_FILE`, `SSL_CERT_DIR` and shared AWS config files, and log an alert when one is set. Before rolling, check that no box's host.env depends on them. On a box outside AWS, `/etc/carlos/credentials` must hold only static keys under `[default]`. The agent rewrites it as root:carlos 0640 on every start.\n" +
+ "\n" +
+ "- **Every dormant switch stays off.** Leave each of these unset, and run none of the commands named, until its own rollout is approved.\n" +
+ "\n" +
+ " - `CARLOS_SWAP` on every box. Two builds at once still has open items, and only the exact word `overlap` arms it.\n" +
+ " - `CARLOS_MOVES_PHASE1` on the console and every box. Don't publish a moves protocol floor (`control/moves-protocol.json`) either.\n" +
+ " - `CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS` and `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS` on the console and every box, and `CARLOS_CONSOLE_ID` on the console. Don't run `carlos system guard-arm` or `carlos system journal-migrate`.\n" +
+ " - `CARLOS_REGION_HA` on every box.\n" +
+ " - `CONSOLE_APP_REGIONS` and `CARLOS_TEMPORARY_APPS` on the console.\n" +
+ " - `CARLOS_REQUIRE_POINTER_DIGEST` on every box. Don't run `carlos channels digests on` yet.\n" +
+ "\n" +
+ "- **Host claims: every roster reader first.** Binaries older than this release reject a signed roster that names a home region. So before anyone publishes the home designation, every reader of `control/host-actors.json` must run this release: every console, every box's agent and root services, the update and rollback tools, and any offline or recovery binary. The designation is write-once, and this roll doesn't publish it.\n" +
+ "\n" +
+ "Verify a download against `SHA256SUMS.txt`. `SHA256SUMS.txt.sig` is the ed25519 signature (base64) over that file, and `carlos update` checks it against its embedded key.\n"
+
+var instanceKeyItems = []string{
+ "On every box, `find <data> -maxdepth 1 -name '*.env' -type l` must print nothing. A `<host>.env` that is a symlink now fails that instance's start. Stop the instance, replace the link with the real file, and treat the token in it as leaked.",
+ "On every box with a replica (`sqlite3 <data>/registry.db \"select host from routes where replica=1\"`), compare each replica's key with its home's. A key is the last `CARLOS_INSTANCE_KEY` line of `<host>.env`, or of `<host>.config.env` when `<host>.env` has none, in the data directory for a flat instance or the instance's own directory for an isolated one. Where the two match, add `<replica-host>=<sha256 of the key>` to `/etc/carlos/replica-home-keys` on the replica's box before it rolls. Write the whole list to a temporary file in `/etc/carlos`, make it root:carlos 0640, and rename it into place. Where they differ, the replica is refused after the roll. Tell its owner first.",
+ "A replica with no key keeps starting where it has a control socket, but its legacy sidecar and any worker row of its own stop at the roll. List them on each box with a replica, and tell their owners.",
+ "Check the agent can read the list: `sudo -u <agent user> test -r /etc/carlos/replica-home-keys && echo ok` must print `ok`.",
+ "After the roll, `journalctl -u carlos-edge --since '<roll time>' | grep 'agent: instance keys:'` must show `0 failed`.",
+}
+var dormantItems = []string{
+ "`CARLOS_SWAP` on every box. Two builds at once still has open items, and only the exact word `overlap` arms it.",
+ "`CARLOS_MOVES_PHASE1` on the console and every box. Don't publish a moves protocol floor (`control/moves-protocol.json`) either.",
+ "`CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS` and `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS` on the console and every box, and `CARLOS_CONSOLE_ID` on the console. Don't run `carlos system guard-arm` or `carlos system journal-migrate`.",
+ "`CARLOS_REGION_HA` on every box.",
+ "`CONSOLE_APP_REGIONS` and `CARLOS_TEMPORARY_APPS` on the console.",
+ "`CARLOS_REQUIRE_POINTER_DIGEST` on every box. Don't run `carlos channels digests on` yet.",
+}
+
+func TestParseNotesNestedItems(t *testing.T) {
+ n := ParseNotes(nestedNotes)
+ if n.Form != "bullets" || len(n.Sections) != 1 || n.Sections[0].Title != "For operators" {
+ t.Fatalf("notes = %+v", n)
+ }
+ b := n.Sections[0].Bullets
+ if len(b) != 4 {
+ t.Fatalf("got %d bullets, want 4: %+v", len(b), b)
+ }
+ want := []struct {
+ lead string
+ items []string
+ }{
+ {"Before any box rolls, run the instance key checks.", instanceKeyItems},
+ {"Root ignores AWS profile, endpoint and trust-store settings.", nil},
+ {"Every dormant switch stays off.", dormantItems},
+ {"Host claims: every roster reader first.", nil},
+ }
+ for i, w := range want {
+ if b[i].Lead != w.lead {
+ t.Errorf("bullet %d lead = %q, want %q", i, b[i].Lead, w.lead)
+ }
+ if !reflect.DeepEqual(b[i].Items, w.items) {
+ t.Errorf("bullet %d items = %q, want %q", i, b[i].Items, w.items)
+ }
+ if strings.Contains(b[i].MD, "\n") {
+ t.Errorf("bullet %d md took a nested line: %q", i, b[i].MD)
+ }
+ }
+ if len(instanceKeyItems) != 5 || len(dormantItems) != 6 {
+ t.Fatalf("fixture lists: %d and %d items", len(instanceKeyItems), len(dormantItems))
+ }
+ out, err := json.Marshal(b[1])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(out), `"items"`) {
+ t.Errorf("a flat bullet marshals items: %s", out)
+ }
+}
+
+func TestParseNotesNestedItemContinuation(t *testing.T) {
+ body := "carlos v0.26.0 (platform 09c5a4a5, 2026-10-13)\n\n" +
+ "- **A.** a.\n - one\n more of one\n - two\n\n" +
+ "after a gap, outside any bullet\n\n- **B.** b.\n"
+ b := ParseNotes(body).Sections[0].Bullets
+ if len(b) != 2 || b[0].MD != "**A.** a." || !reflect.DeepEqual(b[0].Items, []string{"one\nmore of one", "two"}) {
+ t.Fatalf("bullets = %+v", b)
+ }
+ if b[1].MD != "**B.** b." || b[1].Items != nil {
+ t.Fatalf("second bullet = %+v", b[1])
+ }
+}