From fd30dd7621769ae168951df2cd25b2a08b84710a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 18:49:00 -0400 Subject: [PATCH 1/3] Give each release channel its own hosted relay Stable and rc builds keep syncing through https://codeaf.agentfield.ai/fabric; staging builds use the staging relay and every other build (dev tags, make build, tests) the dev relay, so a build nobody tagged never writes to production. CODEAF_SYNC_URL and a pairing's saved relay still win. The three addresses are spelled once, in internal/syncsetup/relayurl.go, and pairing links follow the channel's origin. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf-release/main.go | 7 +- cmd/codeaf-release/service.go | 19 ++++ cmd/codeaf-release/service_test.go | 23 ++++ internal/atlas/pairing.go | 2 +- .../manual/chat/add-or-remove-a-computer.md | 2 +- .../manual/chat/relay-hosted-or-your-own.md | 4 +- .../chat/use-this-on-another-computer.md | 12 +- internal/manual/chat_test.go | 2 + internal/pair/channel_link_test.go | 62 ++++++++++ internal/pair/linkjoin_test.go | 2 +- internal/pair/linkkey.go | 8 +- internal/syncsetup/firstrun.go | 20 +++- internal/syncsetup/manual_default_test.go | 32 +++--- internal/syncsetup/relayurl.go | 81 +++++++++++-- internal/syncsetup/relayurl_law_test.go | 78 +++++++++++++ internal/syncsetup/relayurl_test.go | 107 ++++++++++++++++++ internal/tui3/approve.go | 18 ++- 17 files changed, 435 insertions(+), 44 deletions(-) create mode 100644 internal/pair/channel_link_test.go create mode 100644 internal/syncsetup/relayurl_law_test.go diff --git a/cmd/codeaf-release/main.go b/cmd/codeaf-release/main.go index 805dcd696f..d39e2c1447 100644 --- a/cmd/codeaf-release/main.go +++ b/cmd/codeaf-release/main.go @@ -49,6 +49,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { err = runPrune(args[1:], stdin, stdout) case "service": err = runService(args[1:], stdout) + case "relay-url": + err = runRelayURL(args[1:], stdout) case "promotion-plan": err = runPromotionPlan(args[1:], stdout) case "promotion-message": @@ -93,9 +95,8 @@ func usage(w io.Writer) { Print stable, rc, dev, staging, or other. prune --channel dev|staging Read lines and print expired tags. - service [--url ] - Fail unless the live sync service (the hosted one by default) promises - every capability this build needs. + service [--url ] | relay-url --channel stable|rc|staging|dev + Check a live sync service's capabilities, or print a channel's hosted URL. promotion-plan|promotion-decision|promotion-recheck|promotion-release|promotion-published|promotion-jobs|promotion-push-error|promotion-message Plan, check, and report the staging promotion. See docs/rules/promotion.md. `) diff --git a/cmd/codeaf-release/service.go b/cmd/codeaf-release/service.go index 336fd3e474..098e60912f 100644 --- a/cmd/codeaf-release/service.go +++ b/cmd/codeaf-release/service.go @@ -48,3 +48,22 @@ func describe(missing []directory.Capability) string { } return strings.Join(parts, ", ") } + +// runRelayURL prints the hosted sync service address for one release channel so +// CI scripts read internal/syncsetup/relayurl.go instead of spelling URLs twice. +func runRelayURL(args []string, stdout io.Writer) error { + if len(args) != 2 || args[0] != "--channel" { + return usageErr("relay-url takes --channel ") + } + switch args[1] { + case "stable", "rc": + fmt.Fprintln(stdout, syncsetup.ProductionRelayURL) + case "staging": + fmt.Fprintln(stdout, syncsetup.StagingRelayURL) + case "dev": + fmt.Fprintln(stdout, syncsetup.DevRelayURL) + default: + return usageErr("unknown channel %q (expected stable, rc, staging, or dev)", args[1]) + } + return nil +} diff --git a/cmd/codeaf-release/service_test.go b/cmd/codeaf-release/service_test.go index 1aa0f41526..3ca9927df2 100644 --- a/cmd/codeaf-release/service_test.go +++ b/cmd/codeaf-release/service_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncsetup" ) // TestServiceGateFailsAheadOfTheService pins #2072's release gate: a build @@ -28,3 +29,25 @@ func TestServiceGateFailsAheadOfTheService(t *testing.T) { t.Fatalf("a complete service failed the gate: %q", errs.String()) } } + +func TestRelayURLPrintsEachChannelConstant(t *testing.T) { + cases := map[string]string{ + "stable": syncsetup.ProductionRelayURL, + "rc": syncsetup.ProductionRelayURL, + "staging": syncsetup.StagingRelayURL, + "dev": syncsetup.DevRelayURL, + } + for ch, want := range cases { + var out, errs bytes.Buffer + if code := run([]string{"relay-url", "--channel", ch}, nil, &out, &errs); code != 0 { + t.Fatalf("relay-url --channel %s exited %d: %s", ch, code, errs.String()) + } + if got := strings.TrimSpace(out.String()); got != want { + t.Fatalf("relay-url --channel %s = %q, want %q", ch, got, want) + } + } + var out, errs bytes.Buffer + if code := run([]string{"relay-url", "--channel", "unknown"}, nil, &out, &errs); code != 2 { + t.Fatalf("unknown channel exited %d, want 2", code) + } +} diff --git a/internal/atlas/pairing.go b/internal/atlas/pairing.go index fe532759e9..032ad8b6bc 100644 --- a/internal/atlas/pairing.go +++ b/internal/atlas/pairing.go @@ -22,7 +22,7 @@ var Pairing = &Map{ Files: []FileRef{{Path: "cmd/codeaf/pair.go", Symbols: []string{"runPair"}}}}, // ── services ────────────────────────────────────────────────────── {ID: "hosted", Label: "hosted relay", Short: "default", Kind: KindService, X: 0.38, Y: 0.1, - Summary: "The default service at https://codeaf.agentfield.ai/fabric: a Cloudflare Worker with Durable Objects and R2. It holds the directory, the encrypted blobs and pairing requests, and it cannot decrypt any of them.", + Summary: "The default hosted sync service selected per release channel in internal/syncsetup/relayurl.go: a Cloudflare Worker with Durable Objects and R2. It holds the directory, the encrypted blobs and pairing requests, and it cannot decrypt any of them.", Details: []string{ "Limits: 5 GiB/identity, 1200 req/min/identity, 600/min/device", "Pairing: 10-minute TTL, 10 link creates/hr, 3 pending per IP", diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index 12b8e75511..44d464b4e2 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -46,7 +46,7 @@ Check number: 4821 — the other device shows the same number. Waiting for approval (10 minutes). Press ctrl+c to cancel. ``` -A device with no screen or no browser, such as a server, does the same: run `codeaf pair` there and approve from your laptop. Two things name the same request: the link `https://codeaf.agentfield.ai/p/#`, which opens in a browser and hands over to the installed app (the part after `#` never leaves the browser), and the bare token `.`, for a terminal where nobody can click. `codeaf pair approve` takes either one, and so does pasting into home. When it is approved it says how many workspaces it can now reach: +A device with no screen or no browser, such as a server, does the same: run `codeaf pair` there and approve from your laptop. Two things name the same request: the link (`https://codeaf.agentfield.ai/p/#` on stable releases and release candidates, `https://caf-fabric-staging.instrument-santosh.workers.dev/p/#` on staging builds, or `https://caf-fabric-dev.instrument-santosh.workers.dev/p/#` on dev and local source builds), which opens in a browser and hands over to the installed app (the part after `#` never leaves the browser), and the bare token `.`, for a terminal where nobody can click. Channels do not sync with each other: pair computers on the same release channel (or with the same `CODEAF_SYNC_URL`). `codeaf pair approve` takes either form, and so does pasting into home. When it is approved it says how many workspaces it can now reach: ``` Paired. 3 workspaces available. diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md index ad89717d82..074a1e8815 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -1,6 +1,6 @@ # Hosted relay or your own relay -## Hosted relay or my own — which one do I use, and where does sync go by default +## Hosted relay or my own — which one do I use, where does sync go by default, and which relay each channel uses Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. @@ -13,7 +13,7 @@ A relay is the small service your computers sync through. There are two kinds. B Either way the relay sees only ciphertext and the short list on "What can the relay see". -**Where sync goes by default.** Unset, a computer syncs through codeaf's hosted relay at `https://codeaf.agentfield.ai/fabric`, once this build carries that address. The address lives in one place in the program, `HostedRelayURL`; a build whose value is empty has no default, and then nothing is copied and pairing says `no sync address is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to a sync address, or pass --via
`. A new home sends no relay requests before completing a pairing or offering a code or link. Reading the add-device instructions does not start sync. Older identities without the quiet marker retain their earlier behavior: see "Is my code sent anywhere before I pair" on the page *Pairing your chats with a second computer*. To use your own relay instead, set `CODEAF_SYNC_URL` as below. Turn sync off with `CODEAF_SYNC_URL=off`; pairing cannot override that with `--via`. +**Where sync goes by default.** Unset, each release channel syncs through its own hosted relay: stable releases and release candidates use `https://codeaf.agentfield.ai/fabric`, staging builds use `https://caf-fabric-staging.instrument-santosh.workers.dev`, and dev builds and local builds from source use `https://caf-fabric-dev.instrument-santosh.workers.dev`. Channels do not sync with each other: computers must run the same channel (or set the same `CODEAF_SYNC_URL`) to pair and share chats. A build whose `HostedRelayURL` is empty has no default, and then nothing is copied and pairing says `no sync address is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to a sync address, or pass --via
`. A new home sends no relay requests before completing a pairing or offering a code or link. Reading the add-device instructions does not start sync. Older identities without the quiet marker retain their earlier behavior: see "Is my code sent anywhere before I pair" on the page *Pairing your chats with a second computer*. To use your own relay instead, set `CODEAF_SYNC_URL` as below. Turn sync off with `CODEAF_SYNC_URL=off`; pairing cannot override that with `--via`. ## Point codeaf at a relay — CODEAF_SYNC_URL, url or off diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index b3a57fa2f0..5f461ee26a 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -290,12 +290,12 @@ Your chats live on your own computer first. They are also copied, sealed, to a relay, so your other computers can open them. Which relay is one setting, `CODEAF_SYNC_URL`, and there is no sign-in and no account: -- **Unset** uses codeaf's hosted relay (`https://codeaf.agentfield.ai/fabric`) when this build has one, and says so once, in one +- **Unset** uses this build's channel relay when it has one — `https://codeaf.agentfield.ai/fabric` on stable releases and release candidates, `https://caf-fabric-staging.instrument-santosh.workers.dev` on staging builds, and `https://caf-fabric-dev.instrument-santosh.workers.dev` on dev builds and local builds from source — and says so once, in one line, the first time your chats are copied: `Your chats sync end-to-end encrypted through - codeaf's hosted relay, which only ever sees ciphertext; turn that off with - CODEAF_SYNC_URL=off, or use your own relay with CODEAF_SYNC_URL=.` A computer you - paired with another one uses the relay that pairing saved instead. A build with no - hosted relay has no default, and nothing is copied until you name a relay. + codeaf's fabric (
), a sync service that only ever sees ciphertext; turn that off with + CODEAF_SYNC_URL=off, or use your own with CODEAF_SYNC_URL=.` Channels do not sync with each + other. A computer you paired with another one uses the relay that pairing saved instead. A build + with no hosted relay has no default, and nothing is copied until you name a relay. - **`CODEAF_SYNC_URL=off`** turns the copying off. Nothing leaves the computer, and `codeaf pair` says `sync is off (CODEAF_SYNC_URL=off); pairing needs a sync address`. - **`CODEAF_SYNC_URL=https://relay.example.com`** is your own relay. You run it with @@ -317,7 +317,7 @@ A relay is a small service that passes bytes between the two computers. Your cha What the relay does see is short: the number at the start of the code, the time, how long each message is, and the network address of each computer. It never sees the six digits. -Pairing goes through the relay this computer syncs through. The first that answers wins: the one `CODEAF_SYNC_URL` names, else the one an earlier pairing saved, else codeaf's hosted relay (`https://codeaf.agentfield.ai/fabric`) when this build has one. A build with no hosted relay has no default, so there you must name one: set `CODEAF_SYNC_URL` to the address of a relay, or pass it in the command. With no relay at all, `/pair` and `codeaf pair` say: +Pairing goes through the relay this computer syncs through. The first that answers wins: the one `CODEAF_SYNC_URL` names, else the one an earlier pairing saved, else this channel's hosted relay (`https://codeaf.agentfield.ai/fabric` on stable releases and release candidates, `https://caf-fabric-staging.instrument-santosh.workers.dev` on staging builds, or `https://caf-fabric-dev.instrument-santosh.workers.dev` on dev and local source builds) when this build has one. Channels do not sync with each other, so both computers must run the same channel or point `CODEAF_SYNC_URL` at the same relay. A build with no hosted relay has no default, so there you must name one: set `CODEAF_SYNC_URL` to the address of a relay, or pass it in the command. With no relay at all, `/pair` and `codeaf pair` say: ``` no sync address is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to a sync address, or pass --via
diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index d0ad674c6b..8789e7587a 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4461,6 +4461,8 @@ func TestTheCrossComputerQuestionsReachTheFactThatAnswersThem(t *testing.T) { {"how do I take a chat back from my other computer", "continuing-a-chat-on-another-computer", "Nothing has to be returned or switched off first."}, {"what happens if two computers continue the same chat at once", "continuing-a-chat-on-another-computer", "The slower one sees `another device continued this chat first`"}, {"where does sync go by default", "relay-hosted-or-your-own", "https://codeaf.agentfield.ai/fabric"}, + {"which relay does a dev build or staging build use", "relay-hosted-or-your-own", "https://caf-fabric-dev.instrument-santosh.workers.dev"}, + {"do dev and stable builds sync with each other", "relay-hosted-or-your-own", "Channels do not sync with each other"}, } { found := false for _, section := range Chat().Search(probe.asked, DefaultResults) { diff --git a/internal/pair/channel_link_test.go b/internal/pair/channel_link_test.go new file mode 100644 index 0000000000..c76336f31d --- /dev/null +++ b/internal/pair/channel_link_test.go @@ -0,0 +1,62 @@ +package pair_test + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// Each channel prints pairing links on its own hosted origin, treats links from +// another channel as a foreign service, and still obeys a self-hosted Via. +func TestPairingLinksFollowTheReleaseChannel(t *testing.T) { + key, err := pair.NewLinkKey() + if err != nil { + t.Fatal(err) + } + ref := pair.LinkRef{Code: "k7m2q9xd", Key: key} + cases := []struct { + name string + revision string + want string + foreign string + }{ + {"stable", "v0.3.0", syncsetup.LinkOriginOf(syncsetup.ProductionRelayURL), syncsetup.DevRelayURL}, + {"rc", "v0.3.0-rc.1", syncsetup.LinkOriginOf(syncsetup.ProductionRelayURL), syncsetup.StagingRelayURL}, + {"staging", "staging-20261030-abcdef012345", syncsetup.StagingRelayURL, syncsetup.LinkOriginOf(syncsetup.ProductionRelayURL)}, + {"dev", "dev-20261030-abcdef012345", syncsetup.DevRelayURL, syncsetup.LinkOriginOf(syncsetup.ProductionRelayURL)}, + {"untagged", "deadbeef", syncsetup.DevRelayURL, syncsetup.LinkOriginOf(syncsetup.ProductionRelayURL)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + origin := syncsetup.HostedLinkOriginFor(tc.revision) + if origin != tc.want { + t.Fatalf("HostedLinkOriginFor(%q) = %q, want %q", tc.revision, origin, tc.want) + } + was := pair.LinkOrigin + pair.LinkOrigin = origin + t.Cleanup(func() { pair.LinkOrigin = was }) + + if !strings.HasPrefix(ref.URL(), origin+"/p/"+ref.Code+"#") { + t.Fatalf("URL() = %q, want prefix %q", ref.URL(), origin+"/p/"+ref.Code+"#") + } + lines := pair.InviteLines(pair.Invite{Ref: ref, Check: "4821", ExpiresIn: 10 * time.Minute}) + if !strings.Contains(lines, ref.URL()) { + t.Fatalf("InviteLines lacks channel link %q:\n%s", ref.URL(), lines) + } + if got := pair.LinkRelay(ref.URL()); got != "" { + t.Fatalf("LinkRelay(%q) = %q, want empty for own channel", ref.URL(), got) + } + foreignLink := ref.URLAt(tc.foreign) + if got := pair.LinkRelay(foreignLink); got != tc.foreign { + t.Fatalf("LinkRelay(%q) = %q, want foreign origin %q", foreignLink, got, tc.foreign) + } + custom := pair.InviteLines(pair.Invite{Ref: ref, Via: "https://own.example/relay", Check: "4821", ExpiresIn: 10 * time.Minute}) + if strings.Contains(custom, origin) || !strings.Contains(custom, "https://own.example/relay/p/"+ref.Code) { + t.Fatalf("custom Via did not override channel origin:\n%s", custom) + } + }) + } +} diff --git a/internal/pair/linkjoin_test.go b/internal/pair/linkjoin_test.go index af4474114d..692473aeda 100644 --- a/internal/pair/linkjoin_test.go +++ b/internal/pair/linkjoin_test.go @@ -124,7 +124,7 @@ func TestJoinByLinkApproved(t *testing.T) { ui, done := r.start(t, context.Background()) in := waitInvite(t, ui) - if !strings.HasPrefix(in.Ref.URL(), "https://codeaf.agentfield.ai/p/"+in.Ref.Code+"#") || len(in.Check) != 4 { + if !strings.HasPrefix(in.Ref.URL(), LinkOrigin+"/p/"+in.Ref.Code+"#") || len(in.Check) != 4 { t.Fatalf("the invite looks wrong: %+v", in) } ref, err := ReadLink(in.Ref.Token()) diff --git a/internal/pair/linkkey.go b/internal/pair/linkkey.go index ac08a1af56..45d13b224b 100644 --- a/internal/pair/linkkey.go +++ b/internal/pair/linkkey.go @@ -18,9 +18,11 @@ import ( "golang.org/x/crypto/nacl/secretbox" ) -// LinkOrigin is where a short link points. The page there hands a visitor to the -// installed app, or shows what to run. -const LinkOrigin = "https://codeaf.agentfield.ai" +// LinkOrigin is where a short link points for this binary's release channel, +// set at startup from internal/syncsetup/relayurl.go so the hosted addresses +// are spelled in one place. The page there hands a visitor to the installed +// app, or shows what to run. +var LinkOrigin string // linkKeySize is the secret in the link's fragment, which no server ever sees. const linkKeySize = 16 diff --git a/internal/syncsetup/firstrun.go b/internal/syncsetup/firstrun.go index fa21bd4263..d2a80731b5 100644 --- a/internal/syncsetup/firstrun.go +++ b/internal/syncsetup/firstrun.go @@ -1,14 +1,26 @@ package syncsetup import ( + "net/url" "os" "path/filepath" + "strings" ) -// FirstRunLine is the one sentence a person is told, once, when their chats -// start to sync through the hosted relay without them having chosen it. It asks -// for nothing: there is no sign-in and no consent wall, only the two ways out. -const FirstRunLine = "Your chats sync end-to-end encrypted through codeaf's fabric (codeaf.agentfield.ai/fabric), a sync service that only ever sees ciphertext; turn that off with CODEAF_SYNC_URL=off, or use your own with CODEAF_SYNC_URL=." +// FirstRunLineFor formats the one sentence a person is told, once, when their +// chats start to sync through a hosted relay without them having chosen it. It +// asks for nothing: there is no sign-in and no consent wall, only the two ways +// out. +func FirstRunLineFor(relayURL string) string { + shown := strings.TrimSpace(relayURL) + if u, err := url.Parse(shown); err == nil && u.Host != "" { + shown = u.Host + strings.TrimRight(u.Path, "/") + } + return "Your chats sync end-to-end encrypted through codeaf's fabric (" + shown + "), a sync service that only ever sees ciphertext; turn that off with CODEAF_SYNC_URL=off, or use your own with CODEAF_SYNC_URL=." +} + +// FirstRunLine is the first-run sentence for this binary's hosted sync service. +var FirstRunLine = FirstRunLineFor(HostedRelayURL) // toldFile marks that the line has been said on this computer. const toldFile = "sync-told" diff --git a/internal/syncsetup/manual_default_test.go b/internal/syncsetup/manual_default_test.go index 757fe70393..4e9541054c 100644 --- a/internal/syncsetup/manual_default_test.go +++ b/internal/syncsetup/manual_default_test.go @@ -1,6 +1,7 @@ package syncsetup_test import ( + "slices" "strings" "testing" @@ -8,23 +9,28 @@ import ( "github.com/Agent-Field/codeaf/internal/syncsetup" ) -// hostedAddress is the address the chat's own manual gives for the hosted relay. -const hostedAddress = "https://codeaf.agentfield.ai/fabric" - -// THE MANUAL NAMES THE DEFAULT RELAY THE CODE HAS. Where sync goes by default has -// one source of truth, [syncsetup.HostedRelayURL]. The manual cannot import this -// package (it would be a cycle), so the check lives here: when the constant is -// set it must be the address the pages name, and when the default flips this -// test fails naming the page that has to change with it. +// THE MANUAL NAMES EVERY CHANNEL'S DEFAULT RELAY. Where sync goes by default +// has one source of truth in internal/syncsetup/relayurl.go. The manual cannot +// import this package (it would be a cycle), so the check lives here: every +// channel constant must appear in the pages that explain the default sync +// service, and HostedRelayURL must be one of them. func TestTheManualNamesTheHostedRelayTheCodeDefaultsTo(t *testing.T) { var corpus strings.Builder - for _, section := range manual.Chat().Search("where does sync go by default hosted relay", 50) { + for _, section := range manual.Chat().Search("where does sync go by default hosted relay channel", 50) { corpus.WriteString(section.Body) } - if !strings.Contains(corpus.String(), hostedAddress) { - t.Fatalf("no page about where sync goes by default names %s", hostedAddress) + text := corpus.String() + channels := []string{ + syncsetup.ProductionRelayURL, + syncsetup.StagingRelayURL, + syncsetup.DevRelayURL, + } + for _, addr := range channels { + if !strings.Contains(text, addr) { + t.Errorf("no page about where sync goes by default names %s: update relay-hosted-or-your-own and use-this-on-another-computer", addr) + } } - if hosted := syncsetup.HostedRelayURL; hosted != "" && hosted != hostedAddress { - t.Errorf("the code's default relay is %s but the manual says %s: update relay-hosted-or-your-own and use-this-on-another-computer", hosted, hostedAddress) + if hosted := syncsetup.HostedRelayURL; hosted != "" && !slices.Contains(channels, hosted) { + t.Errorf("the code's default relay %s is not one of the documented channel relays %v", hosted, channels) } } diff --git a/internal/syncsetup/relayurl.go b/internal/syncsetup/relayurl.go index 521b8ba290..e7a39eb8b6 100644 --- a/internal/syncsetup/relayurl.go +++ b/internal/syncsetup/relayurl.go @@ -1,24 +1,91 @@ package syncsetup import ( + "net/url" "os" "path/filepath" "strings" + "github.com/Agent-Field/codeaf/internal/buildinfo" "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/pair" ) -// HostedRelayURL is the central sync service (the fabric) every computer syncs -// through when nobody has said otherwise. It is the one source of truth for that -// address: nothing else in the program spells it. Empty means there is no default, -// so sync stays off until an address is named. The address has a path, which every -// client keeps (see wireauth.Endpoint). -const HostedRelayURL = "https://codeaf.agentfield.ai/fabric" +const ( + // ProductionRelayURL is the central sync service (the fabric) that stable + // releases and release candidates sync through when nobody has said + // otherwise. The address has a path, which every client keeps (see + // wireauth.Endpoint). + ProductionRelayURL = "https://codeaf.agentfield.ai/fabric" + + // StagingRelayURL is the central sync service that staging builds sync + // through when nobody has said otherwise. + StagingRelayURL = "https://caf-fabric-staging.instrument-santosh.workers.dev" + + // DevRelayURL is the central sync service that dev builds, local builds from + // a checkout, and test binaries sync through when nobody has said otherwise, + // so a build nobody tagged can never write to production. + DevRelayURL = "https://caf-fabric-dev.instrument-santosh.workers.dev" +) + +// HostedRelayFor names the default sync service for a binary stamped with +// revision. A stable or release-candidate tag gets production; a staging tag +// gets staging; every other revision gets dev. The three addresses are spelled +// in this file and nowhere else in the program. +func HostedRelayFor(revision string) string { + switch buildinfo.Channel(revision) { + case "stable", "rc": + return ProductionRelayURL + case "staging": + return StagingRelayURL + default: + return DevRelayURL + } +} + +// LinkOriginOf is the scheme and host where a hosted sync service serves its +// short pairing pages (`/p/`), because every hosted Worker serves `/p/` +// at the root of its origin before stripping any base path. +func LinkOriginOf(relayURL string) string { + u, err := url.Parse(strings.TrimSpace(relayURL)) + if err != nil || u.Scheme == "" || u.Host == "" { + return "" + } + return u.Scheme + "://" + u.Host +} + +// HostedLinkOriginFor names the pairing-link origin for a binary stamped with +// revision. +func HostedLinkOriginFor(revision string) string { + return LinkOriginOf(HostedRelayFor(revision)) +} + +// HostedLinkHosts lists the hostnames of every channel's hosted sync service, +// so a pairing surface can recognise a short link from any codeaf channel +// without spelling the addresses outside this file. +func HostedLinkHosts() []string { + hosts := make([]string, 0, 3) + for _, raw := range []string{ProductionRelayURL, StagingRelayURL, DevRelayURL} { + if u, err := url.Parse(raw); err == nil && u.Host != "" { + hosts = append(hosts, u.Host) + } + } + return hosts +} + +// HostedRelayURL is the central sync service this binary syncs through when +// nobody has said otherwise, decided once from the binary's release stamp. +// Empty means there is no default, so sync stays off until an address is named. +var HostedRelayURL = HostedRelayFor(buildinfo.Revision()) // hostedRelay is what the resolver reads, so a test can stand in a hosted relay -// without the constant changing. Nothing but tests assigns it. +// without HostedRelayURL changing. Nothing but tests assigns it. var hostedRelay = HostedRelayURL +func init() { + pair.LinkOrigin = LinkOriginOf(HostedRelayURL) +} + // savedURLFile is where a computer keeps the relay another device told it to // sync through when they were paired. It is a file beside the identity and not a // settings row, because CODEAF_SYNC_URL is deliberately never a row: a persisted diff --git a/internal/syncsetup/relayurl_law_test.go b/internal/syncsetup/relayurl_law_test.go new file mode 100644 index 0000000000..b2c2c61fe2 --- /dev/null +++ b/internal/syncsetup/relayurl_law_test.go @@ -0,0 +1,78 @@ +package syncsetup_test + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// ONE PLACE SPELLS THE THREE HOSTED RELAY ADDRESSES. Every command, pairing +// link and fallback reads them from internal/syncsetup/relayurl.go so two +// packages can never drift onto different relays. +func TestOnlyRelayURLSpellsTheHostedRelayAddresses(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + owner := filepath.Join(root, "internal", "syncsetup", "relayurl.go") + needles := []string{ + syncsetup.ProductionRelayURL, + strings.TrimPrefix(syncsetup.ProductionRelayURL, "https://"), + syncsetup.StagingRelayURL, + strings.TrimPrefix(syncsetup.StagingRelayURL, "https://"), + syncsetup.DevRelayURL, + strings.TrimPrefix(syncsetup.DevRelayURL, "https://"), + } + + fset := token.NewFileSet() + for _, top := range []string{"cmd", "internal"} { + err := filepath.WalkDir(filepath.Join(root, top), func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if d.Name() == "testdata" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") || path == owner { + return nil + } + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + val, err := strconv.Unquote(lit.Value) + if err != nil { + return true + } + for _, needle := range needles { + if strings.Contains(val, needle) { + rel, _ := filepath.Rel(root, path) + pos := fset.Position(lit.Pos()) + t.Errorf("%s:%d spells %q; only internal/syncsetup/relayurl.go may spell the hosted relay addresses", rel, pos.Line, needle) + break + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/syncsetup/relayurl_test.go b/internal/syncsetup/relayurl_test.go index bbcfc35e85..ba04a24ed5 100644 --- a/internal/syncsetup/relayurl_test.go +++ b/internal/syncsetup/relayurl_test.go @@ -117,3 +117,110 @@ func TestNamedLeavesOutOnlyTheHostedDefault(t *testing.T) { t.Fatal("Named must drop the hosted default and keep any other address") } } + +// Each release channel gets its own hosted sync service, and any untagged or +// test revision lands on dev so it can never write to production. +func TestHostedRelayForChannel(t *testing.T) { + cases := []struct { + name string + revision string + want string + origin string + }{ + {"stable release", "v0.2.0", ProductionRelayURL, "https://codeaf.agentfield.ai"}, + {"release candidate", "v0.2.0-rc.1", ProductionRelayURL, "https://codeaf.agentfield.ai"}, + {"staging build", "staging-20261030-abcdef012345", StagingRelayURL, StagingRelayURL}, + {"dev build", "dev-20261030-abcdef012345", DevRelayURL, DevRelayURL}, + {"checkout commit", "abcdef01", DevRelayURL, DevRelayURL}, + {"pseudo-version", "v0.0.0-20261030000000-abcdef012345", DevRelayURL, DevRelayURL}, + {"unstamped test binary", "", DevRelayURL, DevRelayURL}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := HostedRelayFor(tc.revision); got != tc.want { + t.Fatalf("HostedRelayFor(%q) = %q, want %q", tc.revision, got, tc.want) + } + if got := HostedLinkOriginFor(tc.revision); got != tc.origin { + t.Fatalf("HostedLinkOriginFor(%q) = %q, want %q", tc.revision, got, tc.origin) + } + }) + } + if HostedRelayURL != DevRelayURL { + t.Fatalf("test binary HostedRelayURL = %q, want dev default %q", HostedRelayURL, DevRelayURL) + } +} + +// CODEAF_SYNC_URL and a saved pairing relay still beat every channel's hosted +// default, and Named drops only the active channel's hosted default. +func TestOverridesWinOnEveryChannel(t *testing.T) { + channels := []struct { + name string + revision string + foreign string + }{ + {"stable", "v1.2.3", DevRelayURL}, + {"rc", "v1.2.3-rc.2", StagingRelayURL}, + {"staging", "staging-20261030-0123456789ab", ProductionRelayURL}, + {"dev", "dev-20261030-0123456789ab", ProductionRelayURL}, + {"untagged", "deadbeef", ProductionRelayURL}, + } + for _, ch := range channels { + t.Run(ch.name, func(t *testing.T) { + channelDefault := HostedRelayFor(ch.revision) + hosted(t, channelDefault) + + home := machine(t, "") + if got := Resolve(home); got != (Relay{URL: channelDefault, Hosted: true}) { + t.Fatalf("default Resolve = %+v, want hosted %s", got, channelDefault) + } + if Named(channelDefault) != "" { + t.Fatalf("Named(%q) must be empty on channel %s", channelDefault, ch.name) + } + if Named(ch.foreign) != ch.foreign { + t.Fatalf("Named(%q) dropped another channel's address on %s", ch.foreign, ch.name) + } + + if err := SaveRelayURL(home, "https://saved.example"); err != nil { + t.Fatal(err) + } + if got := Resolve(home); got != (Relay{URL: "https://saved.example"}) { + t.Fatalf("saved Resolve = %+v, want saved relay", got) + } + if got := ResolveUnnamed(home); got != (Relay{URL: "https://saved.example"}) { + t.Fatalf("saved ResolveUnnamed = %+v, want saved relay", got) + } + + t.Setenv(URLVar, "https://custom.example") + if got := Resolve(home); got != (Relay{URL: "https://custom.example"}) { + t.Fatalf("variable Resolve = %+v, want variable relay", got) + } + + t.Setenv(URLVar, "off") + if got := Resolve(home); got != (Relay{URL: "off", Off: true}) { + t.Fatalf("off Resolve = %+v, want off", got) + } + }) + } +} + +func TestFirstRunLineNamesEachChannelService(t *testing.T) { + cases := map[string]string{ + ProductionRelayURL: "(codeaf.agentfield.ai/fabric)", + StagingRelayURL: "(caf-fabric-staging.instrument-santosh.workers.dev)", + DevRelayURL: "(caf-fabric-dev.instrument-santosh.workers.dev)", + } + for relayURL, want := range cases { + if got := FirstRunLineFor(relayURL); got == "" || !containsSubstring(got, want) { + t.Fatalf("FirstRunLineFor(%q) = %q, missing %q", relayURL, got, want) + } + } +} + +func containsSubstring(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/tui3/approve.go b/internal/tui3/approve.go index 21629da302..6920be6250 100644 --- a/internal/tui3/approve.go +++ b/internal/tui3/approve.go @@ -34,6 +34,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" ) // PendingDevice is a request to join as the person sees it. Code is what the @@ -93,9 +94,20 @@ const ( ) // linkShape is what link pairing is typed as (contract section 2): a short -// code with an optional key, or a codeaf URL. The six-digit code of the -// older pairing never matches. -var linkShape = regexp.MustCompile(`(?i)^(https?://)?(codeaf\.agentfield\.ai/p/)?[0-9a-hjkmnp-tv-z]{8}([.#].+)?$|^(https?://)?(codeaf\.agentfield\.ai|codeaf\.link)/p/.+$`) +// code with an optional key, or a codeaf URL from any channel's hosted origin +// (plus the retired host). The six-digit code of the older pairing never matches. +var linkShape = buildLinkShape() + +func buildLinkShape() *regexp.Regexp { + hosts := syncsetup.HostedLinkHosts() + quoted := make([]string, 0, len(hosts)+1) + for _, h := range hosts { + quoted = append(quoted, regexp.QuoteMeta(h)) + } + live := strings.Join(quoted, "|") + all := strings.Join(append(quoted, regexp.QuoteMeta("codeaf.link")), "|") + return regexp.MustCompile(`(?i)^(https?://)?((?:` + live + `)/p/)?[0-9a-hjkmnp-tv-z]{8}([.#].+)?$|^(https?://)?(?:` + all + `)/p/.+$`) +} // isPairLink is the one recogniser for a link pasted or typed where no pairing // was asked for: it carries the key, or it is a codeaf URL (the retired host too, so it is answered, not typed into a chat). A bare From 48d088937d384167762b2fce3e26d41fb8128d84 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 18:49:00 -0400 Subject: [PATCH 2/3] Deploy each channel's relay from CI when its code changes A relay job in the release workflow deploys caf-fabric-dev on dev pushes and caf-fabric-staging on staging pushes, only when the relay's input fingerprint differs from the live version's tag, and proves the deploy with the hosted conformance test and codeaf-release service, rolling back on failure. The production relay deploys only once the repository variable RELAY_DEPLOY_PRODUCTION is true. The relay keeps no logs and publishes no preview URLs, matching the live production Worker. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 73 ++++++- Makefile | 3 + docs/rules/ci.md | 16 +- docs/rules/promotion.md | 16 ++ internal/release/workflow_test.go | 212 +++++++++++++++++++- relay/hosted/wrangler.toml | 5 + scripts/relay-deploy.sh | 233 ++++++++++++++++++++++ scripts/relay-deploy_test.sh | 267 ++++++++++++++++++++++++++ scripts/relay-fingerprint.sh | 68 +++++++ scripts/relay-fingerprint_test.sh | 118 ++++++++++++ scripts/relay-wrangler-config.sh | 155 +++++++++++++++ scripts/relay-wrangler-config_test.sh | 163 ++++++++++++++++ 12 files changed, 1321 insertions(+), 8 deletions(-) create mode 100644 scripts/relay-deploy.sh create mode 100644 scripts/relay-deploy_test.sh create mode 100644 scripts/relay-fingerprint.sh create mode 100644 scripts/relay-fingerprint_test.sh create mode 100644 scripts/relay-wrangler-config.sh create mode 100644 scripts/relay-wrangler-config_test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4eaf8dedc..f53df8e447 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -255,16 +255,81 @@ jobs: path: dist/* if-no-files-found: error + # EACH CHANNEL DEPLOYS ITS OWN RELAY BEFORE ITS BINARIES PUBLISH. dev updates + # caf-fabric-dev, staging updates caf-fabric-staging, and main (rc) or a + # stable dispatch updates caf-fabric once RELAY_DEPLOY_PRODUCTION is true. A + # failed deploy or post-deploy proof rolls the Worker back when a prior + # version existed and stops publish; a rollback cannot undo a one-way storage + # upgrade, which is why every relay change reaches dev first and staging a + # week later. + relay: + needs: prepare + permissions: + contents: read + concurrency: + group: relay-${{ (needs.prepare.outputs.channel == 'stable' || needs.prepare.outputs.channel == 'rc') && 'production' || needs.prepare.outputs.channel }} + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check production relay deploy gate + id: gate + env: + CHANNEL: ${{ needs.prepare.outputs.channel }} + RELAY_DEPLOY_PRODUCTION: ${{ vars.RELAY_DEPLOY_PRODUCTION }} + run: | + set -euo pipefail + if [ "$CHANNEL" = "stable" ] || [ "$CHANNEL" = "rc" ]; then + if [ "$RELAY_DEPLOY_PRODUCTION" != "true" ]; then + echo "Production relay deploys are off; set repository variable RELAY_DEPLOY_PRODUCTION=true to enable caf-fabric deploys." + echo "deploy=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + echo "deploy=true" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + if: steps.gate.outputs.deploy == 'true' + with: + fetch-depth: 0 + ref: ${{ needs.prepare.outputs.sha }} + - uses: actions/setup-go@v5 + if: steps.gate.outputs.deploy == 'true' + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v4 + if: steps.gate.outputs.deploy == 'true' + with: + node-version: "22" + cache: npm + cache-dependency-path: relay/hosted/package-lock.json + - name: Install hosted relay dependencies + if: steps.gate.outputs.deploy == 'true' + working-directory: relay/hosted + run: npm ci + - name: Deploy and verify channel relay + if: steps.gate.outputs.deploy == 'true' + env: + CHANNEL: ${{ needs.prepare.outputs.channel }} + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + RELAY_DEPLOY_PRODUCTION: ${{ vars.RELAY_DEPLOY_PRODUCTION }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CODEAF_TELEMETRY: "off" + DO_NOT_TRACK: "1" + run: bash scripts/relay-deploy.sh + # PUBLISH SAYS always() FOR THE SAME REASON build DOES. GitHub skips a job # whose dependency chain contains a skipped job unless the job's own condition # says otherwise, and the chain here always contains one: `test` is skipped on # every dev build by design. The first live run on 2026-09-11 (dev, ef36c3770) # proved it — prepare and build green, eight minutes of binaries uploaded, and - # publish skipped without a word, so no dev release existed. The two explicit - # results below are the whole gate: a red build still publishes nothing. + # publish skipped without a word, so no dev release existed. The three + # explicit results below are the whole gate: a red build or relay deploy still + # publishes nothing. publish: - needs: [prepare, build] - if: always() && needs.prepare.result == 'success' && needs.build.result == 'success' + needs: [prepare, build, relay] + if: always() && needs.prepare.result == 'success' && needs.build.result == 'success' && needs.relay.result == 'success' permissions: contents: write runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index c3a5266165..91cfc66dda 100644 --- a/Makefile +++ b/Makefile @@ -216,6 +216,9 @@ test-tooling: bash scripts/touched-verdict_test.sh bash scripts/one-suite_test.sh bash scripts/shard-test_test.sh + bash scripts/relay-fingerprint_test.sh + bash scripts/relay-wrangler-config_test.sh + bash scripts/relay-deploy_test.sh bash bench/canary/lib/repo_test.sh manual-gates: diff --git a/docs/rules/ci.md b/docs/rules/ci.md index 98cde698ab..26957c0922 100644 --- a/docs/rules/ci.md +++ b/docs/rules/ci.md @@ -57,9 +57,19 @@ of tests exempted from passing on a clean checkout. `Full check` (`ci-full.yml`) runs nightly and validates promotion candidates. It runs full tests, cross builds and container-based remote checks; binary size is reported separately. `Promote to staging` requires a successful fresh Full -check on its exact candidate before moving the pointer. `Release` guards branch -ancestry, tests the applicable release surface and publishes platform builds. -Inspect workflow definitions and live branch rules for current required checks. +check on its exact candidate before moving the pointer. `Release` (`release.yml`) +guards branch ancestry, tests the applicable release surface, builds platform +binaries, and runs the `relay` job before `publish`. The `relay` job hashes the +hosted relay's inputs with `scripts/relay-fingerprint.sh`, skips when the live +Worker's deployment tag already matches, otherwise generates the channel's +Wrangler configuration with `scripts/relay-wrangler-config.sh`, creates the +channel's R2 bucket if needed, deploys the channel's Worker (`caf-fabric-dev` on +`dev`, `caf-fabric-staging` on `staging`, `caf-fabric` on `main` and `stable` +when the repository variable `RELAY_DEPLOY_PRODUCTION` is `true`), proves the +live service with `internal/relay/hosted_test.go` (`CODEAF_RELAY_HOSTED_URL`) +and `codeaf-release service --url`, and rolls back to the recorded prior version +id and stops `publish` if verification fails. Inspect workflow definitions and +live branch rules for current required checks. CI does not run the race detector. Run `go test -race` on the affected package when investigating a concurrency issue. A green ordinary test run does not diff --git a/docs/rules/promotion.md b/docs/rules/promotion.md index 5899791805..3190207f6c 100644 --- a/docs/rules/promotion.md +++ b/docs/rules/promotion.md @@ -62,3 +62,19 @@ make changelog VERSION=v0.2.0 Promote that commit through staging to main. Never write a release-only commit directly onto either pointer. To roll back, publish a forward repair or select an earlier stable release as Latest; do not rewrite branch history. + +## Channel relays + +Each release channel syncs through its own hosted relay Worker, deployed +automatically by `Release`'s `relay` job after `prepare` and before `publish`: + +- `dev` deploys `caf-fabric-dev` (`https://caf-fabric-dev.instrument-santosh.workers.dev`). +- `staging` deploys `caf-fabric-staging` (`https://caf-fabric-staging.instrument-santosh.workers.dev`). +- `main` (`rc`) and `stable` dispatch deploy `caf-fabric` (`https://codeaf.agentfield.ai/fabric`) only when the repository variable `RELAY_DEPLOY_PRODUCTION` is `true`; while unset or anything else, the `relay` job logs that production relay deploys are off and how to enable them, contacts nothing, and lets `publish` proceed against the currently live relay. + +Each deploy is gated by `scripts/relay-fingerprint.sh` (`--tag ` +and `--message `), verified live with `internal/relay/hosted_test.go` +and `codeaf-release service --url`, and rolled back with `wrangler rollback` if +verification fails, blocking `publish`. Because a Worker code rollback cannot +undo a one-way Durable Object or storage upgrade, every relay change lands on +`dev` first and soaks on `staging` for a week before reaching `main`. diff --git a/internal/release/workflow_test.go b/internal/release/workflow_test.go index 11321cccfb..2cfea32078 100644 --- a/internal/release/workflow_test.go +++ b/internal/release/workflow_test.go @@ -77,7 +77,7 @@ func TestReleaseWorkflowKeepsTheChannelContract(t *testing.T) { // carry one, and it must still refuse a red predecessor. for job, want := range map[string]string{ "build": " if: always() && needs.prepare.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')", - "publish": " if: always() && needs.prepare.result == 'success' && needs.build.result == 'success'", + "publish": " if: always() && needs.prepare.result == 'success' && needs.build.result == 'success' && needs.relay.result == 'success'", } { block := regexp.MustCompile(`(?ms)^ ` + job + `:\n needs: [^\n]+\n( if: [^\n]+)`).FindStringSubmatch(workflow) if block == nil || block[1] != want { @@ -268,3 +268,213 @@ func TestPublishedPinExamplesGiveVersionToBash(t *testing.T) { } } } + +func TestRelayJobRunsAfterPrepareAndBlocksPublish(t *testing.T) { + root := repositoryRoot(t) + rawWorkflow, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "release.yml")) + if err != nil { + t.Fatal(err) + } + workflowText := string(rawWorkflow) + + doc := workflowDocument(t, "release.yml") + jobs := obj(t, doc["jobs"]) + relay := obj(t, jobs["relay"]) + if relay["needs"] != "prepare" { + t.Fatalf("relay needs = %v, want prepare", relay["needs"]) + } + perms := obj(t, relay["permissions"]) + if len(perms) != 1 || perms["contents"] != "read" { + t.Fatalf("relay permissions = %v, want only contents: read", perms) + } + concurrency := obj(t, relay["concurrency"]) + wantGroup := "relay-${{ (needs.prepare.outputs.channel == 'stable' || needs.prepare.outputs.channel == 'rc') && 'production' || needs.prepare.outputs.channel }}" + if concurrency["group"] != wantGroup || concurrency["cancel-in-progress"] != false { + t.Fatalf("relay concurrency = %v, want group %q and cancel-in-progress false", concurrency, wantGroup) + } + + publish := obj(t, jobs["publish"]) + publishNeeds := list(t, publish["needs"]) + for _, want := range []string{"prepare", "build", "relay"} { + if !containsNeed(publishNeeds, want) { + t.Errorf("publish needs %v lacks %s", publishNeeds, want) + } + } + if !strings.Contains(stringField(t, publish["if"]), "needs.relay.result == 'success'") { + t.Fatalf("publish condition does not block on relay success: %v", publish["if"]) + } + + steps := list(t, relay["steps"]) + checkedOut := false + pinnedAction := regexp.MustCompile(`^actions/[a-z0-9-]+@v[0-9]+$`) + for i, rawStep := range steps { + step := obj(t, rawStep) + if i > 0 && step["if"] != "steps.gate.outputs.deploy == 'true'" { + t.Errorf("relay step %d lacks gate condition: %v", i, step["if"]) + } + if uses, ok := step["uses"].(string); ok { + if !pinnedAction.MatchString(uses) { + t.Errorf("relay step %d uses unpinned action %q", i, uses) + } + if strings.HasPrefix(uses, "actions/checkout@") { + checkedOut = true + if obj(t, step["with"])["ref"] != "${{ needs.prepare.outputs.sha }}" { + t.Errorf("relay checkout does not check out needs.prepare.outputs.sha: %v", step["with"]) + } + } + } + if run, ok := step["run"].(string); ok && strings.Contains(run, "scripts/") && !checkedOut { + t.Errorf("relay step %d runs a repository script before checkout", i) + } + } + + deployStep := obj(t, steps[len(steps)-1]) + deployEnv := obj(t, deployStep["env"]) + if deployEnv["CLOUDFLARE_ACCOUNT_ID"] != "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" || + deployEnv["CLOUDFLARE_API_TOKEN"] != "${{ secrets.CLOUDFLARE_API_TOKEN }}" { + t.Fatalf("relay deploy step does not read Cloudflare secrets through env: %v", deployEnv) + } + if deployEnv["CODEAF_TELEMETRY"] != "off" || deployEnv["DO_NOT_TRACK"] != "1" { + t.Fatalf("relay deploy step must disable telemetry: %v", deployEnv) + } + + // Neither the workflow nor the relay scripts may trace commands or echo secrets. + secretLeak := regexp.MustCompile(`(?m)(^\s*set\s+-[A-Za-z]*x|echo\s+.*\$\{?CLOUDFLARE_(?:API_TOKEN|ACCOUNT_ID))`) + for _, rel := range []string{ + ".github/workflows/release.yml", + "scripts/relay-deploy.sh", + "scripts/relay-wrangler-config.sh", + "scripts/relay-fingerprint.sh", + } { + raw, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + if secretLeak.Match(raw) { + t.Errorf("%s traces commands or echoes Cloudflare secrets", rel) + } + } + + // Both the workflow comment and the deploy script warn that a rollback + // cannot undo a one-way storage upgrade. + rawDeploy, err := os.ReadFile(filepath.Join(root, "scripts", "relay-deploy.sh")) + if err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{ + "release.yml": workflowText, + "relay-deploy.sh": string(rawDeploy), + } { + flat := strings.Join(strings.Fields(strings.ReplaceAll(content, "#", " ")), " ") + if !strings.Contains(flat, "rollback cannot undo a one-way storage upgrade") { + t.Errorf("%s lacks the one-way storage upgrade rollback explanation", name) + } + } +} + +func TestRelayProductionSwitchDefaultsOffAndDevStagingHaveNoSwitch(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is not on PATH") + } + doc := workflowDocument(t, "release.yml") + relay := obj(t, obj(t, doc["jobs"])["relay"]) + gateStep := obj(t, list(t, relay["steps"])[0]) + gateEnv := obj(t, gateStep["env"]) + if gateEnv["CHANNEL"] != "${{ needs.prepare.outputs.channel }}" || + gateEnv["RELAY_DEPLOY_PRODUCTION"] != "${{ vars.RELAY_DEPLOY_PRODUCTION }}" { + t.Fatalf("gate env = %v", gateEnv) + } + gateScript := stringField(t, gateStep["run"]) + + for _, channel := range []string{"stable", "rc", "staging", "dev"} { + for _, variable := range []string{"", "false", "TRUE", "0", "1", "true"} { + t.Run(channel+"/"+variable, func(t *testing.T) { + outFile := filepath.Join(t.TempDir(), "github_output") + cmd := exec.Command("bash", "-c", gateScript) + cmd.Env = append(os.Environ(), + "CHANNEL="+channel, + "RELAY_DEPLOY_PRODUCTION="+variable, + "GITHUB_OUTPUT="+outFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("gate failed for %s/%q: %v\n%s", channel, variable, err, out) + } + rawOut, err := os.ReadFile(outFile) + if err != nil { + t.Fatal(err) + } + productionChannel := channel == "stable" || channel == "rc" + wantDeploy := !productionChannel || variable == "true" + if wantDeploy { + if strings.TrimSpace(string(rawOut)) != "deploy=true" { + t.Fatalf("output = %q, want deploy=true", rawOut) + } + if strings.TrimSpace(string(out)) != "" { + t.Fatalf("deploy-enabled gate printed unexpected output: %q", out) + } + return + } + if strings.TrimSpace(string(rawOut)) != "deploy=false" { + t.Fatalf("output = %q, want deploy=false", rawOut) + } + lines := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") + if len(lines) != 1 || !strings.Contains(lines[0], "RELAY_DEPLOY_PRODUCTION=true") { + t.Fatalf("production-off gate must print one line explaining how to enable it, got %q", out) + } + }) + } + } +} + +func TestRelayPipelineTouchesOnlyChannelFabricWorkers(t *testing.T) { + root := repositoryRoot(t) + forbidden := []string{"caf-relay-staging", "caf-relay-a", "caf-relay-c", "codeaf-pool"} + checked := []string{ + ".github/workflows/release.yml", + "scripts/relay-wrangler-config.sh", + "scripts/relay-deploy.sh", + "scripts/relay-fingerprint.sh", + } + for _, rel := range checked { + raw, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + text := string(raw) + for _, bad := range forbidden { + if strings.Contains(text, bad) { + t.Errorf("%s names forbidden Worker %q", rel, bad) + } + } + } + + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is not on PATH") + } + gen := filepath.Join(root, "scripts", "relay-wrangler-config.sh") + wantWorker := map[string]string{ + "stable": "caf-fabric", + "rc": "caf-fabric", + "staging": "caf-fabric-staging", + "dev": "caf-fabric-dev", + } + nameLine := regexp.MustCompile(`(?m)^name = "([^"]+)"$`) + for channel, want := range wantWorker { + cmd := exec.Command("bash", gen, channel) + cmd.Env = append(os.Environ(), "CLOUDFLARE_ACCOUNT_ID=acct_test_0123456789") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("relay-wrangler-config.sh %s: %v\n%s", channel, err, out) + } + match := nameLine.FindSubmatch(out) + if match == nil || string(match[1]) != want { + t.Fatalf("channel %s generated name %q, want %q", channel, match, want) + } + for _, bad := range forbidden { + if strings.Contains(string(out), bad) { + t.Errorf("generated config for %s names forbidden Worker %q", channel, bad) + } + } + } +} diff --git a/relay/hosted/wrangler.toml b/relay/hosted/wrangler.toml index 8c9c4c086c..0a3f6fa9d3 100644 --- a/relay/hosted/wrangler.toml +++ b/relay/hosted/wrangler.toml @@ -5,6 +5,11 @@ compatibility_date = "2026-09-01" # Local development and the base of every deployment: `wrangler dev --local` runs this in workerd # with a simulated R2 bucket and Durable Objects. See README.md for what a deployment changes. +# THE RELAY KEEPS NO LOGS. Observability is off, as it is on the live production +# Worker, so no deploy turns on persisted request logs or traces for any channel. +[observability] +enabled = false + [[r2_buckets]] binding = "FRAMES" bucket_name = "codeaf-relay-frames" diff --git a/scripts/relay-deploy.sh b/scripts/relay-deploy.sh new file mode 100644 index 0000000000..94f0907405 --- /dev/null +++ b/scripts/relay-deploy.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# Deploy and verify one release channel's hosted relay Worker. +# +# Production (caf-fabric, used by stable and rc) stays off unless the +# repository variable RELAY_DEPLOY_PRODUCTION is literally "true". Dev and +# staging deploy automatically whenever the relay's input fingerprint differs +# from the live Worker's version tag. +# +# Every deploy proves the live Worker with internal/relay/hosted_test.go and +# `codeaf-release service --url` before binaries can be published. If either +# proof fails and a prior live version id was recorded, Wrangler rolls the +# Worker back to that version and fails the job. Note that a rollback cannot +# undo a one-way storage upgrade, which is why every relay change reaches dev +# first and staging a week later. +set -euo pipefail + +channel="${CHANNEL:-}" +case "$channel" in + stable|rc) + if [ "${RELAY_DEPLOY_PRODUCTION:-}" != "true" ]; then + echo "Production relay deploys are off; set repository variable RELAY_DEPLOY_PRODUCTION=true to enable caf-fabric deploys." + exit 0 + fi + ;; + staging|dev) + ;; + *) + echo "CHANNEL must be one of stable, rc, staging, or dev" >&2 + exit 2 + ;; +esac + +if [ -z "${RELEASE_SHA:-}" ]; then + echo "RELEASE_SHA must be set" >&2 + exit 2 +fi +if [ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then + echo "CLOUDFLARE_ACCOUNT_ID must be set" >&2 + exit 2 +fi +if [ -z "${CLOUDFLARE_API_TOKEN:-}" ]; then + echo "CLOUDFLARE_API_TOKEN must be set" >&2 + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +hosted_dir="$repo_root/relay/hosted" + +work_tmp="$(mktemp -d "${TMPDIR:-/tmp}/codeaf-relay-deploy.XXXXXX")" +wrangler_cfg="${RELAY_WRANGLER_CONFIG_OUT:-$hosted_dir/wrangler.ci.toml}" +cleanup() { + rm -rf "$work_tmp" + rm -f "$wrangler_cfg" +} +trap cleanup EXIT + +redact_secrets() { + ACCOUNT_TO_REDACT="$CLOUDFLARE_ACCOUNT_ID" TOKEN_TO_REDACT="$CLOUDFLARE_API_TOKEN" node -e ' + const fs = require("node:fs"); + let text = fs.readFileSync(0, "utf8"); + for (const key of ["TOKEN_TO_REDACT", "ACCOUNT_TO_REDACT"]) { + const val = process.env[key] || ""; + if (val.length > 0) { + text = text.split(val).join("[REDACTED]"); + } + } + process.stdout.write(text); + ' +} + +fingerprint="$(bash "${RELAY_FINGERPRINT_SCRIPT:-$repo_root/scripts/relay-fingerprint.sh}")" +if [[ ! "$fingerprint" =~ ^[0-9a-f]{64}$ ]]; then + echo "invalid relay fingerprint output" >&2 + exit 1 +fi +tag="${fingerprint:0:16}" + +bash "$repo_root/scripts/relay-wrangler-config.sh" "$channel" > "$wrangler_cfg" +worker_name="$(awk -F'"' '/^name = "/ {print $2; exit}' "$wrangler_cfg")" +bucket_name="$(awk -F'"' '/^bucket_name = "/ {print $2; exit}' "$wrangler_cfg")" +if [ -z "$worker_name" ] || [ -z "$bucket_name" ]; then + echo "failed to read worker_name or bucket_name from generated Wrangler config" >&2 + exit 1 +fi + +relay_url="$(cd "$repo_root" && CODEAF_TELEMETRY=off DO_NOT_TRACK=1 go run ./cmd/codeaf-release relay-url --channel "$channel")" +if [ -z "$relay_url" ]; then + echo "failed to resolve relay URL for channel $channel" >&2 + exit 1 +fi + +api_base="${CLOUDFLARE_API_BASE:-https://api.cloudflare.com/client/v4}" + +cf_get() { + local endpoint="$1" out_body="$2" + printf 'header = "Authorization: Bearer %s"\n' "$CLOUDFLARE_API_TOKEN" | \ + curl -sS -o "$out_body" -w '%{http_code}' --config - "$endpoint" +} + +dep_body="$work_tmp/deployments.json" +dep_code="$(cf_get "${api_base}/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${worker_name}/deployments" "$dep_body")" + +parsed_dep="$(node - "$dep_code" "$dep_body" "$worker_name" <<'JS' +const fs = require('node:fs'); +const [httpCode, bodyPath, workerName] = process.argv.slice(2); +let data = {}; +try { + data = JSON.parse(fs.readFileSync(bodyPath, 'utf8')); +} catch { + if (httpCode === '404') { + process.stdout.write('\n\n'); + process.exit(0); + } + console.error(`Cloudflare deployments response for ${workerName} was not valid JSON (HTTP ${httpCode}).`); + process.exit(1); +} +const codes = Array.isArray(data.errors) ? data.errors.map((e) => Number(e.code)) : []; +if (httpCode === '404' || codes.includes(10007) || codes.includes(10090)) { + process.stdout.write('\n\n'); + process.exit(0); +} +if (!httpCode.startsWith('2') || data.success !== true) { + console.error(`Cloudflare deployments lookup failed for ${workerName} (HTTP ${httpCode}, error codes: ${codes.join(',') || 'none'}).`); + process.exit(1); +} +const deployments = (data.result && Array.isArray(data.result.deployments)) ? data.result.deployments : []; +if (deployments.length === 0) { + process.stdout.write('\n\n'); + process.exit(0); +} +const latest = deployments[0] || {}; +const versions = Array.isArray(latest.versions) ? latest.versions : []; +const primary = versions.find((v) => v && v.percentage === 100) || versions[0] || {}; +const versionId = typeof primary.version_id === 'string' ? primary.version_id : ''; +const ann = (latest.annotations && typeof latest.annotations === 'object') ? latest.annotations : {}; +const tag = typeof ann['workers/tag'] === 'string' ? ann['workers/tag'] : ''; +process.stdout.write(`${versionId}\n${tag}\n`); +JS +)" + +live_version_id="$(printf '%s\n' "$parsed_dep" | sed -n '1p')" +live_tag="$(printf '%s\n' "$parsed_dep" | sed -n '2p')" + +if [ -n "$live_version_id" ] && [ -z "$live_tag" ]; then + ver_body="$work_tmp/version.json" + ver_code="$(cf_get "${api_base}/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${worker_name}/versions/${live_version_id}" "$ver_body")" + live_tag="$(node - "$ver_code" "$ver_body" "$worker_name" <<'JS' +const fs = require('node:fs'); +const [httpCode, bodyPath, workerName] = process.argv.slice(2); +if (httpCode === '404') { + process.exit(0); +} +let data = {}; +try { + data = JSON.parse(fs.readFileSync(bodyPath, 'utf8')); +} catch { + console.error(`Cloudflare version response for ${workerName} was not valid JSON (HTTP ${httpCode}).`); + process.exit(1); +} +if (!httpCode.startsWith('2') || data.success !== true) { + const codes = Array.isArray(data.errors) ? data.errors.map((e) => Number(e.code)) : []; + console.error(`Cloudflare version lookup failed for ${workerName} (HTTP ${httpCode}, error codes: ${codes.join(',') || 'none'}).`); + process.exit(1); +} +const ann = (data.result && data.result.annotations && typeof data.result.annotations === 'object') ? data.result.annotations : {}; +const tag = typeof ann['workers/tag'] === 'string' ? ann['workers/tag'] : ''; +process.stdout.write(tag); +JS +)" +fi + +if [ -n "$live_tag" ] && [ "$live_tag" = "$tag" ]; then + echo "Relay ${worker_name} already matches fingerprint tag ${tag}; skipping deploy." + exit 0 +fi + +set +e +r2_out="$( + cd "$hosted_dir" && \ + CI=true WRANGLER_SEND_METRICS=false WRANGLER_NO_SKILLS_UPDATE_PROMPTS=true \ + npx wrangler r2 bucket create "$bucket_name" --config "$wrangler_cfg" &1 +)" +r2_rc=$? +set -e +if [ "$r2_rc" -ne 0 ]; then + if printf '%s\n' "$r2_out" | grep -Eq 'code:[[:space:]]*10004|\[code:[[:space:]]*10004\]|already exists'; then + echo "R2 bucket ${bucket_name} already exists." + else + printf '%s\n' "$r2_out" | redact_secrets >&2 + exit "$r2_rc" + fi +else + printf '%s\n' "$r2_out" | redact_secrets +fi + +echo "Deploying ${worker_name} (${relay_url}) with tag ${tag} and message ${RELEASE_SHA} (prior version: ${live_version_id:-none})." +( + cd "$hosted_dir" + CI=true WRANGLER_SEND_METRICS=false WRANGLER_NO_SKILLS_UPDATE_PROMPTS=true \ + npx wrangler deploy --config "$wrangler_cfg" --tag "$tag" --message "$RELEASE_SHA" &1 | redact_secrets + +verify_live_relay() { + ( + cd "$repo_root" && + CODEAF_TELEMETRY=off DO_NOT_TRACK=1 CODEAF_RELAY_HOSTED_URL="$relay_url" \ + go test -count=1 -run '^TestAHostedRelay' ./internal/relay && + CODEAF_TELEMETRY=off DO_NOT_TRACK=1 \ + go run ./cmd/codeaf-release service --url "$relay_url" + ) +} + +if ! verify_live_relay; then + echo "Post-deploy verification failed for ${worker_name} at ${relay_url}." >&2 + # A Worker code rollback cannot undo a one-way storage upgrade, which is + # why every relay change reaches dev first and staging a week later. + echo "Note: a rollback cannot undo a one-way storage upgrade, which is why every relay change reaches dev first and staging a week later." >&2 + if [ -n "$live_version_id" ]; then + echo "Rolling back ${worker_name} to recorded version ${live_version_id}." >&2 + ( + cd "$hosted_dir" + CI=true WRANGLER_SEND_METRICS=false WRANGLER_NO_SKILLS_UPDATE_PROMPTS=true \ + npx wrangler rollback "$live_version_id" --config "$wrangler_cfg" \ + --message "Rollback ${worker_name} after failed verification of ${RELEASE_SHA}" --yes &1 | redact_secrets >&2 || echo "Rollback command failed for ${worker_name} version ${live_version_id}." >&2 + else + echo "No prior live version existed for ${worker_name}; nothing to roll back." >&2 + fi + exit 1 +fi + +echo "Verified ${worker_name} at ${relay_url} (tag ${tag})." diff --git a/scripts/relay-deploy_test.sh b/scripts/relay-deploy_test.sh new file mode 100644 index 0000000000..3ab2a607d2 --- /dev/null +++ b/scripts/relay-deploy_test.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +deploy_script="$repo_root/scripts/relay-deploy.sh" + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/relay-deploy-test.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +fake_bin="$tmp/bin" +mkdir -p "$fake_bin" + +# Deterministic 64-hex fingerprint stub (first 16 hex = 0123456789abcdef). +cat > "$tmp/fake-fp.sh" <<'EOF' +#!/usr/bin/env bash +echo "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +EOF +chmod +x "$tmp/fake-fp.sh" + +# Fake curl: records calls, reads stdin config for Bearer token without network I/O. +cat > "$fake_bin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +stdin_cfg="$(cat)" +printf 'CURL %s\n' "$*" >> "$MOCK_LOG" +case "$stdin_cfg" in + *"Authorization: Bearer "*) ;; + *) echo "missing Authorization header in curl --config stdin" >&2; exit 9 ;; +esac +out="" +endpoint="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -w|--config) shift 2 ;; + -*) shift ;; + *) endpoint="$1"; shift ;; + esac +done +case "$endpoint" in + */deployments) + cp "$MOCK_DEPLOYMENTS_BODY" "$out" + printf '%s' "${MOCK_DEPLOYMENTS_CODE:-200}" + ;; + */versions/*) + cp "$MOCK_VERSION_BODY" "$out" + printf '%s' "${MOCK_VERSION_CODE:-200}" + ;; + *) + echo "unexpected curl endpoint: $endpoint" >&2 + exit 9 + ;; +esac +EOF +chmod +x "$fake_bin/curl" + +# Fake npx: intercepts wrangler commands and logs every invocation. +cat > "$fake_bin/npx" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'NPX %s\n' "$*" >> "$MOCK_LOG" +if [ "${MOCK_ECHO_SECRET:-0}" = "1" ]; then + echo "leaked token=${CLOUDFLARE_API_TOKEN} acct=${CLOUDFLARE_ACCOUNT_ID}" +fi +case "$*" in + "wrangler r2 bucket create "*) + if [ "${MOCK_R2_EXISTS:-0}" = "1" ]; then + echo "✘ [ERROR] A request to the Cloudflare API failed. [code: 10004] The bucket you tried to create already exists, and you own it." >&2 + exit 1 + fi + echo "Created bucket." + ;; + "wrangler deploy "*) + echo "Deployed." + ;; + "wrangler rollback "*) + echo "Rolled back." + ;; + *) + echo "unexpected npx command: $*" >&2 + exit 9 + ;; +esac +EOF +chmod +x "$fake_bin/npx" + +# Fake go: handles relay-url, hosted_test.go conformance, and codeaf-release service --url. +cat > "$fake_bin/go" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'GO %s (HOSTED_URL=%s TELEMETRY=%s DNT=%s)\n' \ + "$*" "${CODEAF_RELAY_HOSTED_URL:-}" "${CODEAF_TELEMETRY:-}" "${DO_NOT_TRACK:-}" >> "$MOCK_LOG" +case "$*" in + "run ./cmd/codeaf-release relay-url --channel stable"|"run ./cmd/codeaf-release relay-url --channel rc") + echo "https://codeaf.agentfield.ai/fabric" + ;; + "run ./cmd/codeaf-release relay-url --channel staging") + echo "https://caf-fabric-staging.instrument-santosh.workers.dev" + ;; + "run ./cmd/codeaf-release relay-url --channel dev") + echo "https://caf-fabric-dev.instrument-santosh.workers.dev" + ;; + "test -count=1 -run ^TestAHostedRelay ./internal/relay") + if [ "${MOCK_FAIL_HOSTED_TEST:-0}" = "1" ]; then + echo "FAIL hosted_test" >&2 + exit 1 + fi + echo "ok internal/relay" + ;; + "run ./cmd/codeaf-release service --url "*) + if [ "${MOCK_FAIL_SERVICE_GATE:-0}" = "1" ]; then + echo "missing capability" >&2 + exit 1 + fi + echo "service ok" + ;; + *) + echo "unexpected go command: $*" >&2 + exit 9 + ;; +esac +EOF +chmod +x "$fake_bin/go" + +run_deploy() { + PATH="$fake_bin:$PATH" \ + MOCK_LOG="$tmp/mock.log" \ + MOCK_DEPLOYMENTS_BODY="$tmp/deployments.json" \ + MOCK_VERSION_BODY="$tmp/version.json" \ + RELAY_FINGERPRINT_SCRIPT="$tmp/fake-fp.sh" \ + RELAY_WRANGLER_CONFIG_OUT="$tmp/wrangler.ci.toml" \ + bash "$deploy_script" "$@" +} + +# 1. Production is OFF unless RELAY_DEPLOY_PRODUCTION is literally "true". +for ch in stable rc; do + for val in "" "false" "TRUE" "1" "yes"; do + : > "$tmp/mock.log" + out="$(CHANNEL="$ch" RELAY_DEPLOY_PRODUCTION="$val" run_deploy)" + [ "$(printf '%s\n' "$out" | wc -l | tr -d ' ')" = "1" ] || fail "expected 1 log line when production off ($ch/$val), got: $out" + [[ "$out" == *"RELAY_DEPLOY_PRODUCTION=true"* ]] || fail "missing enablement hint: $out" + [ ! -s "$tmp/mock.log" ] || fail "production-off run contacted tools: $(cat "$tmp/mock.log")" + done +done + +# 2. Matching live tag skips with one log line and deploys nothing. +: > "$tmp/mock.log" +cat > "$tmp/deployments.json" <<'EOF' +{"success":true,"result":{"deployments":[{"id":"dep-1","versions":[{"version_id":"ver-live-1","percentage":100}]}]}} +EOF +cat > "$tmp/version.json" <<'EOF' +{"success":true,"result":{"id":"ver-live-1","annotations":{"workers/tag":"0123456789abcdef"}}} +EOF +out="$( + CHANNEL="dev" \ + RELEASE_SHA="deadbeefcafe" \ + CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ + CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ + run_deploy +)" +[ "$(printf '%s\n' "$out" | wc -l | tr -d ' ')" = "1" ] || fail "expected 1 skip line, got: $out" +[[ "$out" == *"already matches fingerprint tag 0123456789abcdef; skipping deploy."* ]] || fail "unexpected skip line: $out" +if grep -q '^NPX ' "$tmp/mock.log"; then + fail "skip path invoked wrangler: $(cat "$tmp/mock.log")" +fi + +# 3. Missing tag (today's live Worker) triggers deploy + both proofs; existing R2 bucket (10004) is idempotent; secrets are redacted. +: > "$tmp/mock.log" +cat > "$tmp/version.json" <<'EOF' +{"success":true,"result":{"id":"ver-untagged","annotations":{}}} +EOF +out="$( + CHANNEL="staging" \ + RELEASE_SHA="abc123456789" \ + CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ + CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ + MOCK_R2_EXISTS="1" \ + MOCK_ECHO_SECRET="1" \ + run_deploy 2>&1 +)" +[[ "$out" != *"acct_secret_xyz"* ]] || fail "CLOUDFLARE_ACCOUNT_ID leaked in output: $out" +[[ "$out" != *"tok_secret_xyz"* ]] || fail "CLOUDFLARE_API_TOKEN leaked in output: $out" +grep -Fq "NPX wrangler r2 bucket create caf-fabric-staging-frames" "$tmp/mock.log" || fail "missing R2 create for staging" +grep -Fq "NPX wrangler deploy --config $tmp/wrangler.ci.toml --tag 0123456789abcdef --message abc123456789" "$tmp/mock.log" || fail "missing wrangler deploy with tag and message" +grep -Fq "GO test -count=1 -run ^TestAHostedRelay ./internal/relay (HOSTED_URL=https://caf-fabric-staging.instrument-santosh.workers.dev TELEMETRY=off DNT=1)" "$tmp/mock.log" || fail "missing hosted_test proof" +grep -Fq "GO run ./cmd/codeaf-release service --url https://caf-fabric-staging.instrument-santosh.workers.dev" "$tmp/mock.log" || fail "missing codeaf-release service proof" + +# 4. Production deploys caf-fabric when RELAY_DEPLOY_PRODUCTION=true. +: > "$tmp/mock.log" +CHANNEL="stable" \ +RELAY_DEPLOY_PRODUCTION="true" \ +RELEASE_SHA="mainsha999" \ +CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ +CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ +run_deploy >/dev/null +grep -Fq "NPX wrangler r2 bucket create caf-fabric-frames" "$tmp/mock.log" || fail "missing production R2 bucket create" +grep -Fq "GO run ./cmd/codeaf-release service --url https://codeaf.agentfield.ai/fabric" "$tmp/mock.log" || fail "missing production service proof" + +# 5. Failed hosted_test rolls back to recorded live version id, warns about one-way storage upgrades, and fails the job. +: > "$tmp/mock.log" +cat > "$tmp/deployments.json" <<'EOF' +{"success":true,"result":{"deployments":[{"id":"dep-prev","versions":[{"version_id":"ver-rollback-target","percentage":100}]}]}} +EOF +cat > "$tmp/version.json" <<'EOF' +{"success":true,"result":{"id":"ver-rollback-target","annotations":{"workers/tag":"ffffffffffffffff"}}} +EOF +set +e +fail_out="$( + CHANNEL="dev" \ + RELEASE_SHA="badcommit1" \ + CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ + CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ + MOCK_FAIL_HOSTED_TEST="1" \ + run_deploy 2>&1 +)" +fail_rc=$? +set -e +[ "$fail_rc" -ne 0 ] || fail "expected failed hosted_test to fail relay-deploy.sh" +[[ "$fail_out" == *"cannot undo a one-way storage upgrade"* ]] || fail "missing one-way storage upgrade warning: $fail_out" +grep -Fq "NPX wrangler rollback ver-rollback-target --config $tmp/wrangler.ci.toml" "$tmp/mock.log" || fail "missing wrangler rollback to recorded version" + +# 6. Failed codeaf-release service gate also rolls back and fails the job. +: > "$tmp/mock.log" +set +e +fail_out2="$( + CHANNEL="dev" \ + RELEASE_SHA="badcommit2" \ + CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ + CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ + MOCK_FAIL_SERVICE_GATE="1" \ + run_deploy 2>&1 +)" +fail_rc2=$? +set -e +[ "$fail_rc2" -ne 0 ] || fail "expected failed service gate to fail relay-deploy.sh" +grep -Fq "NPX wrangler rollback ver-rollback-target" "$tmp/mock.log" || fail "missing rollback after service gate failure" + +# 7. First deploy of a brand-new Worker (404 / 10007): failure skips rollback cleanly and still fails the job. +: > "$tmp/mock.log" +cat > "$tmp/deployments.json" <<'EOF' +{"success":false,"errors":[{"code":10007,"message":"workers.api.error.script_not_found"}]} +EOF +set +e +first_fail_out="$( + CHANNEL="dev" \ + RELEASE_SHA="firstbad" \ + CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ + CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ + MOCK_DEPLOYMENTS_CODE="404" \ + MOCK_FAIL_SERVICE_GATE="1" \ + run_deploy 2>&1 +)" +first_fail_rc=$? +set -e +[ "$first_fail_rc" -ne 0 ] || fail "expected failed first deploy to fail relay-deploy.sh" +if grep -q "wrangler rollback" "$tmp/mock.log"; then + fail "unexpected rollback when no prior version existed: $(cat "$tmp/mock.log")" +fi +[[ "$first_fail_out" == *"No prior live version existed"* ]] || fail "missing no-prior-version log: $first_fail_out" + +echo "PASS: relay-deploy_test" diff --git a/scripts/relay-fingerprint.sh b/scripts/relay-fingerprint.sh new file mode 100644 index 0000000000..a787a1c310 --- /dev/null +++ b/scripts/relay-fingerprint.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Print one deterministic sha256 over the hosted relay's inputs (paths and +# contents, in sorted order): +# - relay/hosted/** (tracked source inputs, excluding node_modules, .wrangler +# and generated build products) +# - scripts/raft-hosted-build.sh +# - scripts/raft-proof-footprint.sh +# - go.mod and go.sum +# - the Go source of every package in +# `GOOS=wasip1 GOARCH=wasm go list -deps ./cmd/codeaf-raft-wasm` +# that lives in this module +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +root="$(cd "${RELAY_FINGERPRINT_ROOT:-$repo_root}" && pwd)" + +list_file="$(mktemp "${TMPDIR:-/tmp}/relay-fingerprint.XXXXXX")" +trap 'rm -f "$list_file"' EXIT + +( + cd "$root" + + find relay/hosted \ + \( -path 'relay/hosted/node_modules' -o -path 'relay/hosted/.wrangler' \) -prune \ + -o -type f \ + ! -path 'relay/hosted/src/raft/core.wasm' \ + ! -path 'relay/hosted/wrangler.ci.toml' \ + -print + + for fixed in \ + scripts/raft-hosted-build.sh \ + scripts/raft-proof-footprint.sh \ + go.mod \ + go.sum; do + if [ ! -f "$fixed" ]; then + echo "missing relay input: $fixed" >&2 + exit 1 + fi + printf '%s\n' "$fixed" + done + + GOOS=wasip1 GOARCH=wasm go list -buildvcs=false -deps \ + -f '{{if .Module}}{{range .GoFiles}}{{$.Dir}}/{{.}} +{{end}}{{range .CgoFiles}}{{$.Dir}}/{{.}} +{{end}}{{range .SFiles}}{{$.Dir}}/{{.}} +{{end}}{{range .HFiles}}{{$.Dir}}/{{.}} +{{end}}{{range .EmbedFiles}}{{$.Dir}}/{{.}} +{{end}}{{end}}' \ + ./cmd/codeaf-raft-wasm | while IFS= read -r abs_path; do + [ -n "$abs_path" ] || continue + case "$abs_path" in + "$root"/*) + printf '%s\n' "${abs_path#"$root"/}" + ;; + esac + done +) | LC_ALL=C sort -u > "$list_file" + +if [ ! -s "$list_file" ]; then + echo "relay-fingerprint found no input files" >&2 + exit 1 +fi + +( + cd "$root" + tr '\n' '\0' < "$list_file" | xargs -0 sha256sum -b -- +) | sha256sum | awk '{print $1}' diff --git a/scripts/relay-fingerprint_test.sh b/scripts/relay-fingerprint_test.sh new file mode 100644 index 0000000000..a9ac4587ee --- /dev/null +++ b/scripts/relay-fingerprint_test.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +fp_script="$repo_root/scripts/relay-fingerprint.sh" + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/relay-fingerprint-test.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# Build a lightweight sandbox mirror of the module so mutations never touch the real tree. +sandbox="$tmp/repo" +mkdir -p "$sandbox/scripts" "$sandbox/relay" "$sandbox/cmd" "$sandbox/internal/tui3" +cp "$repo_root/go.mod" "$repo_root/go.sum" "$sandbox/" +cp "$repo_root/scripts/raft-hosted-build.sh" "$repo_root/scripts/raft-proof-footprint.sh" "$sandbox/scripts/" +cp -R "$repo_root/cmd/codeaf-raft-wasm" "$sandbox/cmd/" + +# Copy relay/hosted without heavy node_modules/.wrangler. +mkdir -p "$sandbox/relay/hosted" +( + cd "$repo_root/relay/hosted" + find . \( -path './node_modules' -o -path './.wrangler' \) -prune -o -type f -print | while IFS= read -r rel; do + mkdir -p "$sandbox/relay/hosted/$(dirname "$rel")" + cp "$rel" "$sandbox/relay/hosted/$rel" + done +) + +# Copy every in-tree wasm dependency package (including replaced third_party/raft) plus one unrelated package. +( + cd "$repo_root" + GOOS=wasip1 GOARCH=wasm go list -buildvcs=false -deps \ + -f '{{if .Module}}{{.Dir}}{{end}}' \ + ./cmd/codeaf-raft-wasm +) | sort -u | while IFS= read -r pkg_dir; do + [ -n "$pkg_dir" ] || continue + case "$pkg_dir" in + "$repo_root"/*) + rel_dir="${pkg_dir#"$repo_root"/}" + mkdir -p "$sandbox/$(dirname "$rel_dir")" + rm -rf "$sandbox/$rel_dir" + cp -R "$pkg_dir" "$sandbox/$rel_dir" + ;; + esac +done +if [ -f "$repo_root/third_party/raft/go.mod" ]; then + mkdir -p "$sandbox/third_party/raft" + cp "$repo_root/third_party/raft/go.mod" "$sandbox/third_party/raft/go.mod" + if [ -f "$repo_root/third_party/raft/go.sum" ]; then + cp "$repo_root/third_party/raft/go.sum" "$sandbox/third_party/raft/go.sum" + fi +fi + +cat > "$sandbox/internal/tui3/unrelated.go" <<'EOF' +package tui3 + +const Unrelated = 1 +EOF + +base="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +[[ "$base" =~ ^[0-9a-f]{64}$ ]] || fail "fingerprint is not 64 lowercase hex chars: $base" + +again="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +[ "$base" = "$again" ] || fail "fingerprint is not deterministic: $base vs $again" + +# Generated/ignored files under relay/hosted must not change the fingerprint. +mkdir -p "$sandbox/relay/hosted/node_modules/fake" "$sandbox/relay/hosted/.wrangler/state" "$sandbox/relay/hosted/src/raft" +echo "ignored" > "$sandbox/relay/hosted/node_modules/fake/index.js" +echo "ignored" > "$sandbox/relay/hosted/.wrangler/state/cache" +echo "ignored" > "$sandbox/relay/hosted/src/raft/core.wasm" +echo "ignored" > "$sandbox/relay/hosted/wrangler.ci.toml" +with_ignored="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +[ "$base" = "$with_ignored" ] || fail "ignored artifacts changed fingerprint: $base vs $with_ignored" + +# Unrelated Go packages and test files must not change the fingerprint. +echo "// comment" >> "$sandbox/internal/tui3/unrelated.go" +echo "// test edit" >> "$sandbox/internal/raftcore/core_test.go" +after_unrelated="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +[ "$base" = "$after_unrelated" ] || fail "unrelated package or _test.go changed fingerprint: $base vs $after_unrelated" + +# Every declared input category must change the fingerprint when its content or path changes. +check_mutation_changes() { + local label="$1" target="$2" trailer="$3" + cp "$target" "$tmp/saved" + printf '\n%s\n' "$trailer" >> "$target" + local mutated + mutated="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" + mv "$tmp/saved" "$target" + [ "$mutated" != "$base" ] || fail "editing $label did not change fingerprint" + local restored + restored="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" + [ "$restored" = "$base" ] || fail "restoring $label did not restore fingerprint" +} + +check_mutation_changes "relay/hosted JS" "$sandbox/relay/hosted/src/worker.js" "// change" +check_mutation_changes "raft-hosted-build.sh" "$sandbox/scripts/raft-hosted-build.sh" "# change" +check_mutation_changes "raft-proof-footprint.sh" "$sandbox/scripts/raft-proof-footprint.sh" "# change" +check_mutation_changes "go.mod" "$sandbox/go.mod" "// change" +check_mutation_changes "go.sum" "$sandbox/go.sum" "" + +# Mutating a Go source file in a wasm-dep package must change the fingerprint. +cp "$sandbox/internal/raftcore/core.go" "$tmp/core.go.bak" +printf '\n// relay fingerprint probe\n' >> "$sandbox/internal/raftcore/core.go" +wasm_mutated="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +mv "$tmp/core.go.bak" "$sandbox/internal/raftcore/core.go" +[ "$wasm_mutated" != "$base" ] || fail "editing wasm dependency Go source did not change fingerprint" + +# Renaming a file while keeping identical contents must change the fingerprint (paths are hashed). +mv "$sandbox/relay/hosted/src/base.js" "$sandbox/relay/hosted/src/base-renamed.js" +renamed_fp="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +mv "$sandbox/relay/hosted/src/base-renamed.js" "$sandbox/relay/hosted/src/base.js" +[ "$renamed_fp" != "$base" ] || fail "renaming a file with identical bytes did not change fingerprint" + +echo "PASS: relay-fingerprint_test" diff --git a/scripts/relay-wrangler-config.sh b/scripts/relay-wrangler-config.sh new file mode 100644 index 0000000000..01cf601879 --- /dev/null +++ b/scripts/relay-wrangler-config.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Generate a per-channel Wrangler configuration from relay/hosted/wrangler.toml. +# +# relay/hosted/wrangler.toml is the single source of Durable Object bindings, +# migrations, build command, observability and baseline rate-limit ids. This +# script substitutes only the channel's Worker name, account id, R2 bucket, +# base path, routing mode, and per-account rate-limit namespace offset. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +channel="$1" +case "$channel" in + production|stable|rc) + mode="production" + worker_name="caf-fabric" + bucket_name="caf-fabric-frames" + base_path="/fabric" + rate_offset=0 + ;; + staging) + mode="workers_dev" + worker_name="caf-fabric-staging" + bucket_name="caf-fabric-staging-frames" + base_path="" + rate_offset=100 + ;; + dev) + mode="workers_dev" + worker_name="caf-fabric-dev" + bucket_name="caf-fabric-dev-frames" + base_path="" + rate_offset=200 + ;; + *) + echo "unknown relay channel: $channel (expected production, stable, rc, staging, or dev)" >&2 + exit 2 + ;; +esac + +account_id="${CLOUDFLARE_ACCOUNT_ID:-}" +if [ -z "$account_id" ]; then + echo "CLOUDFLARE_ACCOUNT_ID must be set in the environment" >&2 + exit 2 +fi +case "$account_id" in + *[!A-Za-z0-9_-]*) + echo "CLOUDFLARE_ACCOUNT_ID contains unexpected characters" >&2 + exit 2 + ;; +esac + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +base_toml="${RELAY_WRANGLER_BASE:-$repo_root/relay/hosted/wrangler.toml}" + +if [ ! -f "$base_toml" ]; then + echo "missing base Wrangler config: $base_toml" >&2 + exit 1 +fi + +awk \ + -v mode="$mode" \ + -v worker_name="$worker_name" \ + -v bucket_name="$bucket_name" \ + -v base_path="$base_path" \ + -v rate_offset="$rate_offset" \ + -v account_id="$account_id" \ +' +BEGIN { + section = "" + saw_name = 0 + saw_compat = 0 + saw_bucket = 0 + saw_vars = 0 + emitted_base_path = 0 + ratelimits = 0 +} +/^[[:space:]]*\[/ { + if (section == "[vars]" && !emitted_base_path) { + printf "BASE_PATH = \"%s\"\n", base_path + emitted_base_path = 1 + } + section = $0 + sub(/^[[:space:]]+/, "", section) + sub(/[[:space:]]+$/, "", section) + if (section == "[vars]") { + saw_vars = 1 + } + print $0 + next +} +section == "" && /^name[[:space:]]*=/ { + printf "name = \"%s\"\n", worker_name + saw_name = 1 + next +} +section == "" && /^compatibility_date[[:space:]]*=/ { + print $0 + printf "account_id = \"%s\"\n", account_id + # No channel publishes a preview URL per version, as the live production + # Worker does not; the one address a channel answers on is its own. + print "preview_urls = false" + if (mode == "production") { + print "workers_dev = false" + print "routes = [" + print " { pattern = \"codeaf.agentfield.ai/fabric*\", zone_name = \"agentfield.ai\" }," + print " { pattern = \"codeaf.agentfield.ai/p/*\", zone_name = \"agentfield.ai\" }," + print "]" + } else { + print "workers_dev = true" + } + saw_compat = 1 + next +} +section == "[[r2_buckets]]" && /^bucket_name[[:space:]]*=/ { + printf "bucket_name = \"%s\"\n", bucket_name + saw_bucket = 1 + next +} +section == "[vars]" && /^BASE_PATH[[:space:]]*=/ { + printf "BASE_PATH = \"%s\"\n", base_path + emitted_base_path = 1 + next +} +section == "[[ratelimits]]" && /^namespace_id[[:space:]]*=/ { + val = $0 + sub(/^[^"]*"/, "", val) + sub(/".*$/, "", val) + if (val !~ /^[0-9]+$/) { + print "non-numeric ratelimit namespace_id: " $0 > "/dev/stderr" + exit 1 + } + printf "namespace_id = \"%d\"\n", (val + 0) + rate_offset + ratelimits++ + next +} +{ + print $0 +} +END { + if (section == "[vars]" && !emitted_base_path) { + printf "BASE_PATH = \"%s\"\n", base_path + emitted_base_path = 1 + } + if (!saw_name || !saw_compat || !saw_bucket || !saw_vars || ratelimits < 2) { + printf "incomplete base wrangler.toml (name=%d compat=%d bucket=%d vars=%d ratelimits=%d)\n", \ + saw_name, saw_compat, saw_bucket, saw_vars, ratelimits > "/dev/stderr" + exit 1 + } +} +' "$base_toml" diff --git a/scripts/relay-wrangler-config_test.sh b/scripts/relay-wrangler-config_test.sh new file mode 100644 index 0000000000..3468d6bf35 --- /dev/null +++ b/scripts/relay-wrangler-config_test.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +gen="$repo_root/scripts/relay-wrangler-config.sh" + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/relay-wrangler-config-test.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# Refuses missing or unknown channel and missing account id. +if CLOUDFLARE_ACCOUNT_ID="acct_test" bash "$gen" >/dev/null 2>&1; then + fail "expected missing channel to fail" +fi +if CLOUDFLARE_ACCOUNT_ID="acct_test" bash "$gen" preview >/dev/null 2>&1; then + fail "expected unknown channel to fail" +fi +if env -u CLOUDFLARE_ACCOUNT_ID bash "$gen" dev >/dev/null 2>&1; then + fail "expected missing CLOUDFLARE_ACCOUNT_ID to fail" +fi +if CLOUDFLARE_ACCOUNT_ID='bad"quote' bash "$gen" dev >/dev/null 2>&1; then + fail "expected malformed CLOUDFLARE_ACCOUNT_ID to fail" +fi + +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" bash "$gen" production > "$tmp/prod.toml" +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" bash "$gen" stable > "$tmp/stable.toml" +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" bash "$gen" rc > "$tmp/rc.toml" +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" bash "$gen" staging > "$tmp/staging.toml" +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" bash "$gen" dev > "$tmp/dev.toml" + +cmp -s "$tmp/prod.toml" "$tmp/stable.toml" || fail "stable config differs from production" +cmp -s "$tmp/prod.toml" "$tmp/rc.toml" || fail "rc config differs from production" + +assert_contains() { + local file="$1" needle="$2" + grep -Fq -- "$needle" "$file" || fail "$file missing: $needle" +} + +assert_not_contains() { + local file="$1" needle="$2" + if grep -Fq -- "$needle" "$file"; then + fail "$file unexpectedly contains: $needle" + fi +} + +# Production shape: routes, BASE_PATH = "/fabric", workers_dev = false, observability, 1001/1002. +assert_contains "$tmp/prod.toml" 'name = "caf-fabric"' +assert_contains "$tmp/prod.toml" 'account_id = "acct_0123456789abcdef"' +assert_contains "$tmp/prod.toml" 'workers_dev = false' +assert_contains "$tmp/prod.toml" '{ pattern = "codeaf.agentfield.ai/fabric*", zone_name = "agentfield.ai" }' +assert_contains "$tmp/prod.toml" '{ pattern = "codeaf.agentfield.ai/p/*", zone_name = "agentfield.ai" }' +assert_contains "$tmp/prod.toml" 'bucket_name = "caf-fabric-frames"' +assert_contains "$tmp/prod.toml" 'BASE_PATH = "/fabric"' +assert_contains "$tmp/prod.toml" '[observability]' +assert_contains "$tmp/prod.toml" 'preview_urls = false' +assert_not_contains "$tmp/prod.toml" '[observability.logs]' +assert_not_contains "$tmp/prod.toml" '[observability.traces]' +assert_not_contains "$tmp/prod.toml" 'persist = true' +assert_contains "$tmp/prod.toml" 'namespace_id = "1001"' +assert_contains "$tmp/prod.toml" 'namespace_id = "1002"' +assert_not_contains "$tmp/prod.toml" 'workers_dev = true' + +# Staging shape: workers_dev = true, no routes, empty BASE_PATH, +100 rate-limit ids. +assert_contains "$tmp/staging.toml" 'name = "caf-fabric-staging"' +assert_contains "$tmp/staging.toml" 'account_id = "acct_0123456789abcdef"' +assert_contains "$tmp/staging.toml" 'workers_dev = true' +assert_contains "$tmp/staging.toml" 'bucket_name = "caf-fabric-staging-frames"' +assert_contains "$tmp/staging.toml" 'BASE_PATH = ""' +assert_contains "$tmp/staging.toml" '[observability]' +assert_contains "$tmp/staging.toml" 'namespace_id = "1101"' +assert_contains "$tmp/staging.toml" 'namespace_id = "1102"' +assert_not_contains "$tmp/staging.toml" 'routes =' +assert_not_contains "$tmp/staging.toml" 'zone_name' + +# Dev shape: workers_dev = true, no routes, empty BASE_PATH, +200 rate-limit ids. +assert_contains "$tmp/dev.toml" 'name = "caf-fabric-dev"' +assert_contains "$tmp/dev.toml" 'account_id = "acct_0123456789abcdef"' +assert_contains "$tmp/dev.toml" 'workers_dev = true' +assert_contains "$tmp/dev.toml" 'bucket_name = "caf-fabric-dev-frames"' +assert_contains "$tmp/dev.toml" 'BASE_PATH = ""' +assert_contains "$tmp/dev.toml" '[observability]' +assert_contains "$tmp/dev.toml" 'namespace_id = "1201"' +assert_contains "$tmp/dev.toml" 'namespace_id = "1202"' +assert_not_contains "$tmp/dev.toml" 'routes =' +assert_not_contains "$tmp/dev.toml" 'zone_name' + +# Migrations come straight from the base wrangler.toml and are never copied in the script. +cp "$repo_root/relay/hosted/wrangler.toml" "$tmp/extended-base.toml" +cat >> "$tmp/extended-base.toml" <<'EOF' + +[[migrations]] +tag = "v99-append-only-check" +new_sqlite_classes = ["FutureDO"] +EOF +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" RELAY_WRANGLER_BASE="$tmp/extended-base.toml" \ + bash "$gen" dev > "$tmp/extended-out.toml" +assert_contains "$tmp/extended-out.toml" 'tag = "v99-append-only-check"' + +# Forbidden Workers never appear in the generator, deploy script, workflow, or generated configs. +for forbidden in caf-relay-staging caf-relay-a caf-relay-c codeaf-pool; do + for target in \ + "$gen" \ + "$repo_root/.github/workflows/release.yml" \ + "$tmp/prod.toml" \ + "$tmp/staging.toml" \ + "$tmp/dev.toml"; do + assert_not_contains "$target" "$forbidden" + done + if [ -f "$repo_root/scripts/relay-deploy.sh" ]; then + assert_not_contains "$repo_root/scripts/relay-deploy.sh" "$forbidden" + fi +done + +# Validate parsed TOML structure through Wrangler's own config reader when installed. +if [ -d "$repo_root/relay/hosted/node_modules/wrangler" ] && command -v node >/dev/null 2>&1; then + node --input-type=module - "$repo_root/relay/hosted" "$tmp/prod.toml" "$tmp/staging.toml" "$tmp/dev.toml" <<'JS' +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const [hostedDir, prodPath, stagingPath, devPath] = process.argv.slice(2); +const require = createRequire(hostedDir + '/package.json'); +const { experimental_readRawConfig } = require('wrangler'); + +const prod = experimental_readRawConfig({ config: prodPath }).rawConfig; +assert.equal(prod.name, 'caf-fabric'); +assert.equal(prod.account_id, 'acct_0123456789abcdef'); +assert.equal(prod.workers_dev, false); +assert.equal(prod.vars.BASE_PATH, '/fabric'); +assert.equal(prod.vars.LEASE_POLICY, 'amended'); +assert.equal(prod.r2_buckets[0].bucket_name, 'caf-fabric-frames'); +assert.equal(prod.routes.length, 2); +// The live production Worker keeps no logs and publishes no preview URLs, and +// no channel's deploy may change that. +assert.equal(prod.observability.enabled, false); +assert.equal(prod.observability.logs, undefined); +assert.equal(prod.observability.traces, undefined); +assert.equal(prod.preview_urls, false); +assert.deepEqual(prod.ratelimits.map((r) => r.namespace_id), ['1001', '1002']); + +const staging = experimental_readRawConfig({ config: stagingPath }).rawConfig; +assert.equal(staging.name, 'caf-fabric-staging'); +assert.equal(staging.workers_dev, true); +assert.equal(staging.routes, undefined); +assert.equal(staging.vars.BASE_PATH, ''); +assert.equal(staging.r2_buckets[0].bucket_name, 'caf-fabric-staging-frames'); +assert.deepEqual(staging.ratelimits.map((r) => r.namespace_id), ['1101', '1102']); + +const dev = experimental_readRawConfig({ config: devPath }).rawConfig; +assert.equal(dev.name, 'caf-fabric-dev'); +assert.equal(dev.workers_dev, true); +assert.equal(dev.routes, undefined); +assert.equal(dev.vars.BASE_PATH, ''); +assert.equal(dev.r2_buckets[0].bucket_name, 'caf-fabric-dev-frames'); +assert.deepEqual(dev.ratelimits.map((r) => r.namespace_id), ['1201', '1202']); +JS +fi + +echo "PASS: relay-wrangler-config_test" From af966d5e0755f453e5f9007ec99e520cbfab3566 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 18:51:18 -0400 Subject: [PATCH 3/3] Add the change entry for #2079 Co-Authored-By: Claude Opus 5.5 --- .../changes/unreleased/2079-relay-per-channel.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/changes/unreleased/2079-relay-per-channel.md diff --git a/docs/changes/unreleased/2079-relay-per-channel.md b/docs/changes/unreleased/2079-relay-per-channel.md new file mode 100644 index 0000000000..5189fd7546 --- /dev/null +++ b/docs/changes/unreleased/2079-relay-per-channel.md @@ -0,0 +1,16 @@ +--- +kind: changed +title: Each release channel syncs through its own relay, and CI deploys a relay when its code changes +pr: 2079 +surface: [remote, build, docs] +invalidates: + - "Every build synced through https://codeaf.agentfield.ai/fabric by default; now only stable and rc do, staging builds use https://caf-fabric-staging.instrument-santosh.workers.dev, and dev tags, make build and tests use https://caf-fabric-dev.instrument-santosh.workers.dev. Channels do not sync with each other. CODEAF_SYNC_URL and a pairing's saved relay still win." + - "syncsetup.HostedRelayURL was one constant; it is now the channel's address, chosen from the build's revision, and the three addresses are ProductionRelayURL, StagingRelayURL and DevRelayURL in internal/syncsetup/relayurl.go." + - "pair.LinkOrigin was the constant https://codeaf.agentfield.ai; it is now set at startup to the channel's relay origin, and pairing links from any channel's origin are recognised." + - "Nothing deployed the hosted relay, so the client could move ahead of it unnoticed; the release workflow's relay job now deploys caf-fabric-dev on dev pushes and caf-fabric-staging on staging pushes when the relay's fingerprint changed, proves each deploy, and rolls back on failure. A failed relay job stops that channel's publish." + - "The production relay caf-fabric is still deployed by hand; CI deploys it only once the repository variable RELAY_DEPLOY_PRODUCTION is true, which it is not." +--- + +A relay change now reaches the dev relay with the dev build that needs it, and +staging a week later, before production is ever touched. A Worker rollback +cannot undo a one-way storage upgrade, which is why that order matters.