Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/fast-deploy-webhook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
29 changes: 22 additions & 7 deletions internal/deploy/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions internal/deploy/target_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading