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>
4 files changed,
+160
−3
docs/site/changelog.json+21 −1internal/changelog/build.go+1 −1internal/changelog/parse.go+23 −1internal/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]) |
| + } |
| +} |