diff --git a/docs/fast-deploy-webhook.md b/docs/fast-deploy-webhook.md index bdfc8de..2932fe3 100644 --- a/docs/fast-deploy-webhook.md +++ b/docs/fast-deploy-webhook.md @@ -4,7 +4,7 @@ The operator turns a content-only git push into a Cloudflare KV content update ("fast deploy"), with **no** code redeploy. Flow: ``` -git push (main, .deco/blocks/** only) +git push (main, content-only: .deco/blocks/** + src/server/cms/blocks.gen.json) → GitHub webhook → operator POST /webhooks/github (HMAC-verified) → DeploymentTarget (cloudflare-workers) resolves the repo's Deco CR, creates/updates a Decofile CR (target: tanstack-kv) @@ -77,7 +77,9 @@ or non-content changes — or per repo (Settings → Webhooks). Either way: - **Events:** "Just the push event" The operator processes only pushes to the repo's default branch whose changed files -are all under `.deco/blocks/**`. A `ping` event (sent on setup) returns `200`. +are **all content paths**: under `.deco/blocks/**` or the regenerated bundled snapshot +`src/server/cms/blocks.gen.json` (Studio commits both together). Any other changed +file makes it a code push → normal build path. A `ping` event (sent on setup) returns `200`. ## 4. Verify diff --git a/internal/deploy/target.go b/internal/deploy/target.go index d97c969..6954cbd 100644 --- a/internal/deploy/target.go +++ b/internal/deploy/target.go @@ -19,9 +19,18 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" ) -// blocksPrefix is the repo-relative directory whose changes count as a -// content-only push (a fast-deploy candidate). -const blocksPrefix = ".deco/blocks/" +// Content-only definition: a push is a fast-deploy candidate when EVERY changed +// file matches one of these paths. Studio content commits touch two places — +// the decofile blocks themselves and the regenerated bundled snapshot it keeps +// in lockstep for HMR. +const ( + // blocksPrefix is the repo-relative directory holding the decofile blocks. + blocksPrefix = ".deco/blocks/" + // blocksGenFile is the bundled snapshot Studio regenerates alongside a + // content commit (used for HMR). It is derived from .deco/blocks, so its + // presence in a diff carries no code change. + blocksGenFile = "src/server/cms/blocks.gen.json" +) // PushEvent is the normalized git push the webhook hands to a DeploymentTarget. type PushEvent struct { @@ -73,21 +82,27 @@ func (r *TargetRegistry) Plan(ctx context.Context, push PushEvent, site SiteConf return t.Plan(ctx, push, site) } -// isContentOnly reports whether every changed file is under .deco/blocks/ (a -// content-only push). An empty file list is treated as not content-only — we -// can't prove it's content, so we don't fast-deploy it. +// isContentOnly reports whether every changed file is a content path — under +// .deco/blocks/ or the regenerated bundled snapshot (blocksGenFile). An empty +// file list is treated as not content-only — we can't prove it's content, so +// we don't fast-deploy it. func isContentOnly(files []string) bool { if len(files) == 0 { return false } for _, f := range files { - if !strings.HasPrefix(f, blocksPrefix) { + if !isContentPath(f) { return false } } return true } +// isContentPath reports whether a single changed file counts as content. +func isContentPath(f string) bool { + return strings.HasPrefix(f, blocksPrefix) || f == blocksGenFile +} + // maxNameLen is the Kubernetes object-name ceiling (DNS-1123 label). const maxNameLen = 63 diff --git a/internal/deploy/target_test.go b/internal/deploy/target_test.go new file mode 100644 index 0000000..46d5af1 --- /dev/null +++ b/internal/deploy/target_test.go @@ -0,0 +1,52 @@ +package deploy + +import "testing" + +func TestIsContentOnly(t *testing.T) { + cases := []struct { + name string + files []string + want bool + }{ + {"empty list is not content", nil, false}, + {"blocks only", []string{".deco/blocks/pages-home.json"}, true}, + {"gen snapshot only", []string{"src/server/cms/blocks.gen.json"}, true}, + {"blocks + gen snapshot (studio commit)", []string{ + ".deco/blocks/pages-home.json", + ".deco/blocks/loaders-products.json", + "src/server/cms/blocks.gen.json", + }, true}, + {"mixed with code", []string{".deco/blocks/pages-home.json", "src/components/Header.tsx"}, false}, + {"code only", []string{"src/components/Header.tsx"}, false}, + {"sibling dir does not match prefix", []string{".deco/blocks-old/x.json"}, false}, + {"other gen files are code", []string{"src/server/cms/sections.gen.ts"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isContentOnly(tc.files); got != tc.want { + t.Fatalf("isContentOnly(%v) = %v, want %v", tc.files, got, tc.want) + } + }) + } +} + +func TestDecofileName(t *testing.T) { + cases := []struct { + site string + want string + }{ + {"storefront-tanstack", "fastdeploy-storefront-tanstack"}, + {"My_Site.X", "fastdeploy-my-site-x"}, + {"", "fastdeploy-site"}, + } + for _, tc := range cases { + if got := decofileName(tc.site); got != tc.want { + t.Fatalf("decofileName(%q) = %q, want %q", tc.site, got, tc.want) + } + } + // 63-char cap holds for absurdly long site names. + long := decofileName("this-is-a-very-long-site-name-that-would-overflow-the-kubernetes-limit") + if len(long) > 63 { + t.Fatalf("decofileName exceeded 63 chars: %d", len(long)) + } +}