package changelog import ( "encoding/json" "reflect" "strings" "testing" ) func TestBranchName(t *testing.T) { cases := map[string]string{ "fix/canary-one-label\n\nSquashed from fix/canary-one-label into main.": "fix/canary-one-label", "deploy-credential: a thing (#229)\n\nSquashed from design-deploy-credential into main.": "design-deploy-credential", "Merge branch 'tenant-iso-mail' into main\n\nMail: members can't adopt": "tenant-iso-mail", "Merge pull request #409 from carlosframework/edge-replica-placements\n\nprovision": "edge-replica-placements", "Merge pull request #12 from someone/feat/nested-name": "feat/nested-name", "console: a Database tile on the app Overview": "", } for msg, want := range cases { if got := BranchName(msg); got != want { t.Errorf("BranchName(%q) = %q, want %q", msg, got, want) } } } func TestDiscussionRefs(t *testing.T) { msg := "console: refuse (#199 stopgap)\n\nSee discussion 207 and amadan #155; also #199 again.\nissue #412 and PR #409." got := DiscussionRefs(msg) want := []int{155, 199, 207, 409, 412} if !reflect.DeepEqual(got, want) { t.Fatalf("DiscussionRefs = %v, want %v", got, want) } if got := DiscussionRefs("colour #fff and a #1abc anchor"); len(got) != 0 { t.Fatalf("DiscussionRefs(hex) = %v, want none", got) } } const bulletNotes = "carlos v0.24.0 (platform eef3e121, 2026-10-06)\n\n" + "- **Canaries have a one-label address, and can be created again.** A canary's host is now `-`. You need this release's CLI.\n" + "- **One address, one app.** `carlos instances create` and `carlos canary` are refused when another app holds the host.\n" + " It continues here.\n\n" + "**For operators**\n\n" + "- **Check every box's secrets key before rolling the boxes.** A box now takes an exec instance only if it can open the config.\n\n" + "Verify a download against `SHA256SUMS.txt`; `SHA256SUMS.txt.sig` is the ed25519 signature (base64) over that file.\n" func TestParseNotesBullets(t *testing.T) { n := ParseNotes(bulletNotes) if n.Form != "bullets" { t.Fatalf("Form = %q", n.Form) } if len(n.Sections) != 2 || n.Sections[0].Title != "" || n.Sections[1].Title != "For operators" { t.Fatalf("sections = %+v", n.Sections) } b := n.Sections[0].Bullets if len(b) != 2 || b[0].Lead != "Canaries have a one-label address, and can be created again." { t.Fatalf("bullets = %+v", b) } if !strings.HasSuffix(b[1].MD, "the host.\nIt continues here.") { t.Fatalf("continuation lost: %q", b[1].MD) } if len(n.Sections[1].Bullets) != 1 { t.Fatalf("operator bullets = %+v", n.Sections[1].Bullets) } for _, s := range n.Sections { for _, bl := range s.Bullets { if strings.Contains(bl.MD, "Verify a download") { t.Fatalf("boilerplate kept: %q", bl.MD) } } } } func TestParseNotesOperatorHeadingVariants(t *testing.T) { for _, h := range []string{"**Before you roll**", "**Before you roll a console to this release**", "**For operators**"} { body := "carlos v0.20.0 (platform 0d2fdd44, 2026-09-23)\n\n- **A.** a.\n\n" + h + "\n\n- **B.** b.\n" n := ParseNotes(body) if len(n.Sections) != 2 || n.Sections[1].Title != strings.Trim(h, "*") { t.Errorf("%s: sections = %+v", h, n.Sections) } } } func TestParseNotesMarkdown(t *testing.T) { body := "## carlos deploy — seconds to live\n\nOne command.\n\n```\n## not a heading\n```\n\n### Three things\n\ntext\n\n## Install\n\n- Homebrew: brew install\n\nSHA256SUMS.txt is signed.\n\n## After install\n\nkept\n\nšŸ¤– Generated with [Claude Code](https://claude.com/claude-code)\n" n := ParseNotes(body) if n.Form != "markdown" { t.Fatalf("Form = %q", n.Form) } for _, want := range []string{"#### carlos deploy", "##### Three things", "\n## not a heading\n", "#### After install"} { if !strings.Contains(n.MD, want) { t.Errorf("markdown lacks %q:\n%s", want, n.MD) } } for _, gone := range []string{"Install", "Homebrew", "SHA256SUMS", "Generated with"} { if strings.Contains(n.MD, gone) { t.Errorf("markdown kept %q:\n%s", gone, n.MD) } } } func TestParseNotesOneParagraph(t *testing.T) { n := ParseNotes("First tagged release of `carlos`. Built from carlosframework/platform @ v0.1.0.\n") if n.Form != "markdown" || !strings.HasPrefix(n.MD, "First tagged release") { t.Fatalf("notes = %+v", n) } } func TestParseNotesEmailLikeTextSurvives(t *testing.T) { body := "carlos v0.23.0 (platform x, 2026-10-05)\n\n- **Operators.** live for bill@ on Tito's console.\n" n := ParseNotes(body) if len(n.Sections) != 1 || !strings.Contains(n.Sections[0].Bullets[0].MD, "bill@") { t.Fatalf("notes = %+v", n) } } func TestCLIDocLinks(t *testing.T) { headings := map[string]string{ "carlos db": "carlos-db", "carlos store": "carlos-store", "carlos store scan": "carlos-store-scan", "carlos services": "carlos-services", } md := "`carlos db pull --app ` and `carlos store scan` and `carlos services create --role deploy` and `carlos nope` and `carlos db query`" got := CLIDocLinks(md, headings) want := []string{"/docs/cli#carlos-db", "/docs/cli#carlos-store-scan", "/docs/cli#carlos-services"} if !reflect.DeepEqual(got, want) { 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 `.env` the first time it starts, and starts a replica only with its home's key.\n" + "\n" + " - On every box, `find -maxdepth 1 -name '*.env' -type l` must print nothing. A `.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 /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 `.env`, or of `.config.env` when `.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 `=` 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 test -r /etc/carlos/replica-home-keys && echo ok` must print `ok`.\n" + " - After the roll, `journalctl -u carlos-edge --since '' | 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 -maxdepth 1 -name '*.env' -type l` must print nothing. A `.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 /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 `.env`, or of `.config.env` when `.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 `=` 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 test -r /etc/carlos/replica-home-keys && echo ok` must print `ok`.", "After the roll, `journalctl -u carlos-edge --since '' | 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]) } }