From f833f979ea81c70b61ff78a60d12b8ccaa180a6b Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sun, 21 Jun 2026 15:09:12 +0200 Subject: [PATCH] fix(project): exempt cdn_channel consumers from released-source validation; review polish - Extend ValidateLayout exemption so cdn_channel consumers skip root/include/ derive/manifest requirements (they resolve entirely from Source.* at runtime). - Add TestValidateLayoutAcceptsCDNChannelConsumerWithoutReleasedSourceFields. - Fix inverted precedence comment in CDN-base resolution block (autogen.go:325). - Add broken-YAML subtest to TestResolveCDNChannelHardFail. - Add assumption comment to autogenConsumerManagedLockKeyMatcher re folder_name+preserve. Co-Authored-By: Claude Sonnet 4.6 --- internal/project/project.go | 8 ++-- internal/project/project_test.go | 45 ++++++++++++++++++++ internal/topdata/autogen.go | 6 ++- internal/topdata/autogen_cdn_channel_test.go | 12 ++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/internal/project/project.go b/internal/project/project.go index 8ad602a..40ba9c7 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -1682,10 +1682,12 @@ func validateAutogenConfig(cfg AutogenConfig) []error { if strings.TrimSpace(consumer.Mode) == "accessory_visualeffects" && accessoryVisualeffectsConfigConfigured(consumer.AccessoryVisualeffects) { failures = append(failures, validateAccessoryVisualeffectsConfig(fieldPrefix+".accessory_visualeffects", consumer.AccessoryVisualeffects)...) } - // A manifest_file consumer reads a pre-resolved local manifest, so the - // released-source fields (root/include/derive/manifest) are never read + // A manifest_file consumer reads a pre-resolved local manifest, and a + // cdn_channel consumer resolves entirely from Source.* at runtime, so + // neither reads the released-source fields (root/include/derive/manifest) // at build time — don't require them. See resolveAutogenConsumerManifest. - if strings.TrimSpace(consumer.ManifestFile) == "" { + if strings.TrimSpace(consumer.ManifestFile) == "" && + strings.TrimSpace(consumer.Source.Kind) != "cdn_channel" { if strings.TrimSpace(consumer.Root) == "" { failures = append(failures, fmt.Errorf("%s.root is required", fieldPrefix)) } diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 73f40a4..9c10e9c 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -1425,6 +1425,51 @@ func TestValidateLayoutRejectsInvalidAccessoryVisualeffectsNamingConfig(t *testi } } +func TestValidateLayoutAcceptsCDNChannelConsumerWithoutReleasedSourceFields(t *testing.T) { + // cdn_channel consumers resolve entirely from Source.* at runtime; they must + // not be required to supply root/include/derive/manifest fields. + root := t.TempDir() + mkdirAll(t, filepath.Join(root, "src")) + mkdirAll(t, filepath.Join(root, "build")) + + proj := &Project{ + Root: root, + Config: Config{ + Module: ModuleConfig{Name: "Test", ResRef: "test"}, + Paths: PathConfig{Source: "src", Build: "build"}, + Autogen: AutogenConfig{ + Consumers: []AutogenConsumerConfig{ + { + ID: "accessory_visualeffects", + Producer: "accessory_visualeffects", + Dataset: "visualeffects", + Mode: "accessory_visualeffects", + Optional: true, + AccessoryVisualeffects: AccessoryVisualeffectsConfig{ + Groups: map[string]AccessoryVisualeffectGroupConfig{ + "head_accessories": {Prefix: "head_acc_"}, + "chest_accessories": {Prefix: "chest_acc_"}, + "head_decorations": {Prefix: "head_dec_"}, + "head_features": {Prefix: "head_feat_"}, + }, + }, + Source: AutogenSourceConfig{ + Kind: "cdn_channel", + ChannelsPath: "releases/haks/channels.json", + ManifestPath: "releases/haks/{tag}/vfxs.yml", + }, + // Intentionally omitted: Root, Include, Derive, Manifest + }, + }, + }, + }, + } + + if err := proj.ValidateLayout(); err != nil { + t.Fatalf("cdn_channel consumer should pass ValidateLayout without released-source fields, got: %v", err) + } +} + func TestLoadRejectsLegacyAccessoryVisualeffectsNamingFields(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` diff --git a/internal/topdata/autogen.go b/internal/topdata/autogen.go index 32d93bb..9168cbd 100644 --- a/internal/topdata/autogen.go +++ b/internal/topdata/autogen.go @@ -189,6 +189,10 @@ func autogenConsumerManagedLockKeyMatcher(consumer project.AutogenConsumerConfig prefix := dataset + ":" // Build the group set from the resolved policy so consumers that omit // AccessoryVisualeffects.Groups still match the four default group names. + // NOTE: this matcher assumes the default folder_name+preserve group + // representation. Keys produced under case:lower or + // group_token_source:prefix would not match and would escape pruning; + // no production config uses those combinations. policy := resolveAccessoryVisualeffectsPolicy(consumer, nil) groups := make(map[string]struct{}, len(policy.Groups)) for group := range policy.Groups { @@ -322,7 +326,7 @@ func resolveCDNChannelManifest(p *project.Project, consumer project.AutogenConsu channel = deriveAssetChannel(p.Root) } - // 3. CDN base: env override, then config literal, then baked default. + // 3. CDN base: baked default, then env override, then config literal (most specific wins). cdnBase := defaultBunnyCDNBase if envName := strings.TrimSpace(src.CDNBaseEnv); envName != "" { if v := strings.TrimSpace(os.Getenv(envName)); v != "" { diff --git a/internal/topdata/autogen_cdn_channel_test.go b/internal/topdata/autogen_cdn_channel_test.go index 3454150..efa042a 100644 --- a/internal/topdata/autogen_cdn_channel_test.go +++ b/internal/topdata/autogen_cdn_channel_test.go @@ -159,6 +159,18 @@ func TestResolveCDNChannelHardFail(t *testing.T) { t.Fatalf("want HARD fail, got %v", err) } }) + + t.Run("malformed vfxs.yml: syntactically broken YAML", func(t *testing.T) { + srv := cdnServer(t, map[string]string{ + "/releases/haks/channels.json": `{"current":"v1"}`, + "/releases/haks/v1/vfxs.yml": "assets:\n - path: [unterminated", + }) + t.Setenv("BUNNY_CDN_BASE", srv.URL) + _, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil) + if err == nil || errorIsUnavailable(err) { + t.Fatalf("want HARD fail on broken YAML, got %v", err) + } + }) } func TestResolveCDNChannelOfflineOverride(t *testing.T) {