From b5b1d220118bd0f32405289f08d3da422e197cb8 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 30 Aug 2026 14:30:07 +0000 Subject: [PATCH] Expand family publish policies into concrete rules --- docs/config.md | 2 +- docs/internal/artifacts.md | 2 + docs/internal/configuration.md | 4 +- docs/roadmap/implementation.md | 2 +- internal/config/artifact_families.go | 62 +++++++++++++++++++++++ internal/config/artifact_families_test.go | 29 +++++++++++ internal/config/load.go | 1 + internal/config/pipeline_composition.go | 1 + 8 files changed, 100 insertions(+), 3 deletions(-) diff --git a/docs/config.md b/docs/config.md index 1ede008..ee9e7ef 100644 --- a/docs/config.md +++ b/docs/config.md @@ -439,7 +439,7 @@ For each `pipeline.scriptorium.artifact_families.`: | `output_path_pattern` | string | Yes | safe path beneath `artifacts/` with exactly one `{character_id}` token and no other brace syntax | | `member_vars` | map | No | maps an ordinary Scriptorium variable name to a supported canonical character selector | | `member_dependencies` | list | No | unique family keys; each generated member depends on the corresponding generated member of each listed family | -| `publish` | map | No | typed family publish metadata (`enabled`, `required`, `dest_pattern`); it does not yet create publish outputs | +| `publish` | map | No | typed family publish policy (`enabled`, `required`, `dest_pattern`) expanded into concrete publish outputs when enabled | Generated keys are `_` and generated output paths must not collide with explicit artifacts or another generated artifact. Families diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index 3e2a340..0cc6b27 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -64,6 +64,8 @@ Configuration may normalize a family selection into its concrete generated members before this resolver runs. The effective set retains optional family and character origin metadata, but its keys, catalog sources, and runtime lookups remain concrete configured-artifact identities. +Family publish policies are likewise expanded into ordinary configured-source +publish rules during configuration resolution. Configured outputs, including non-executable prerequisites, become available only when the versioned analyze state identifies a current result whose source, contract, canonical configured path, size, and checksum match a confined diff --git a/docs/internal/configuration.md b/docs/internal/configuration.md index dd14a27..65b129f 100644 --- a/docs/internal/configuration.md +++ b/docs/internal/configuration.md @@ -134,4 +134,6 @@ the family-only `narratio.member_artifact.` input form is rewritten to the matching ordinary configured-artifact source. The catalog records those resolved dependency and input identities with their declaring family and party member. No member-artifact source is registered as a runtime policy source. -The typed publish declaration still does not create a publish rule here. +An enabled family publish declaration expands to ordinary configured-artifact +publish rules before the existing publish and lock validators run. Runtime +publication consequently receives no family wildcard or special matcher. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d17c9f9..abb44f5 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -911,7 +911,7 @@ concrete execution, artifact-granular fingerprints, and manifest authority. ## Stage 16 — Family Publish Rule Expansion -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/config/artifact_families.go b/internal/config/artifact_families.go index f94faae..f8882e7 100644 --- a/internal/config/artifact_families.go +++ b/internal/config/artifact_families.go @@ -121,6 +121,9 @@ func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) er catalog.Families[familyKey] = familyOrigin } cfg.Scriptorium.Artifacts = expanded + if err := expandFamilyPublishRules(cfg, catalog); err != nil { + return err + } cfg.Scriptorium.ArtifactFamilies = nil cfg.familyCatalog = catalog cfg.resolution.artifactFamiliesExpanded = true @@ -130,12 +133,71 @@ func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) er if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil { return err } + if err := validatePublish(cfg.Publish, cfg.Scriptorium, cfg.Notarius); err != nil { + return err + } if err := recomputePipelineEffectiveDigest(cfg); err != nil { return fmt.Errorf("refresh expanded pipeline digest: %w", err) } return nil } +func expandFamilyPublishRules(cfg *PipelineConfig, catalog ArtifactFamilyCatalog) error { + if cfg == nil || cfg.Scriptorium == nil { + return nil + } + if cfg.Publish == nil { + for familyKey, family := range catalog.Families { + if family.Publish != nil && family.Publish.Enabled { + return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish requires pipeline.publish", familyKey) + } + } + return nil + } + generated := make([]PublishOutputRule, 0) + explicitSources := make(map[string]struct{}, len(cfg.Publish.Outputs)) + for _, rule := range cfg.Publish.Outputs { + explicitSources[strings.TrimSpace(rule.Source)] = struct{}{} + } + for familyKey, family := range catalog.Families { + if family.Publish == nil || !family.Publish.Enabled { + continue + } + if cfg.resolution == nil || !cfg.resolution.publishDeclared { + return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish requires pipeline.publish", familyKey) + } + pattern := strings.TrimSpace(family.Publish.DestPattern) + if pattern != "" { + if err := validateFamilyOutputPattern("pipeline.scriptorium.artifact_families."+familyKey+".publish.dest_pattern", pattern); err != nil { + return err + } + } + for _, key := range family.Members { + if _, conflict := explicitSources[artifactpolicy.ConfiguredSourceID(key)]; conflict { + return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish conflicts with explicit publish source %q", familyKey, artifactpolicy.ConfiguredSourceID(key)) + } + origin := catalog.Members[key] + dest := cfg.Scriptorium.Artifacts[key].OutputPath + if pattern != "" { + dest = strings.ReplaceAll(pattern, characterIDToken, origin.CharacterID) + } + required := family.Publish.Required + generated = append(generated, PublishOutputRule{Source: artifactpolicy.ConfiguredSourceID(key), Dest: dest, Required: &required}) + } + } + if len(generated) == 0 { + return nil + } + cfg.Publish.Outputs = append(cfg.Publish.Outputs, generated...) + sort.Slice(cfg.Publish.Outputs, func(i, j int) bool { + if cfg.Publish.Outputs[i].Dest == cfg.Publish.Outputs[j].Dest { + return cfg.Publish.Outputs[i].Source < cfg.Publish.Outputs[j].Source + } + return cfg.Publish.Outputs[i].Dest < cfg.Publish.Outputs[j].Dest + }) + return nil +} + func validateFamilyMemberDependencies(families map[string]ScriptoriumArtifactFamilyConfig, familyKeys []string) error { for _, familyKey := range familyKeys { family := families[familyKey] diff --git a/internal/config/artifact_families_test.go b/internal/config/artifact_families_test.go index 1894fde..8f833e3 100644 --- a/internal/config/artifact_families_test.go +++ b/internal/config/artifact_families_test.go @@ -297,6 +297,35 @@ func TestArtifactFamiliesRejectInvalidMemberDependenciesAndSources(t *testing.T) } } +func TestFamilyPublishRulesExpandToConcreteArtifacts(t *testing.T) { + required := true + cfg := &PipelineConfig{ + Scriptorium: &ScriptoriumConfig{Artifacts: map[string]ScriptoriumArtifactConfig{ + "character_meta_alpha": {OutputPath: "artifacts/characters/alpha/meta.md"}, + "character_meta_zeta": {OutputPath: "artifacts/characters/zeta/meta.md"}, + }}, + Publish: &PublishConfig{}, + resolution: &pipelineResolutionMetadata{publishDeclared: true}, + } + catalog := ArtifactFamilyCatalog{Families: map[string]ArtifactFamilyOrigin{ + "character_meta": {Members: []string{"character_meta_alpha", "character_meta_zeta"}, Publish: &ScriptoriumArtifactFamilyPublishConfig{Enabled: true, Required: required, DestPattern: "published/{character_id}.md"}}, + }, Members: map[string]ArtifactFamilyMemberOrigin{ + "character_meta_alpha": {CharacterID: "alpha"}, "character_meta_zeta": {CharacterID: "zeta"}, + }} + if err := expandFamilyPublishRules(cfg, catalog); err != nil { + t.Fatal(err) + } + if got := cfg.Publish.Outputs; len(got) != 2 || got[0].Source != "narratio.artifact.character_meta_alpha" || got[0].Dest != "published/alpha.md" || got[0].Required == nil || !*got[0].Required { + t.Fatalf("generated publish rules = %#v", got) + } + cfg.Publish.Outputs = nil + catalog.Families["character_meta"] = ArtifactFamilyOrigin{Members: []string{"character_meta_alpha"}, Publish: &ScriptoriumArtifactFamilyPublishConfig{Enabled: true}} + cfg.resolution.publishDeclared = false + if err := expandFamilyPublishRules(cfg, catalog); err == nil || !strings.Contains(err.Error(), "requires pipeline.publish") { + t.Fatalf("missing publish error = %v", err) + } +} + const validFamilyYAML = ` character_meta: enabled: true diff --git a/internal/config/load.go b/internal/config/load.go index 708e8d1..f2eb805 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -31,6 +31,7 @@ func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineCo if err != nil { return nil, fmt.Errorf("load pipeline config: %w", err) } + cfg.resolution.publishDeclared = cfg.Publish != nil applyPipelineDefaults(cfg) if err := resolveNotariusPaths(cfg, path); err != nil { return nil, fmt.Errorf("load pipeline config: %w", err) diff --git a/internal/config/pipeline_composition.go b/internal/config/pipeline_composition.go index c4c5d38..a692e22 100644 --- a/internal/config/pipeline_composition.go +++ b/internal/config/pipeline_composition.go @@ -21,6 +21,7 @@ type pipelineResolutionMetadata struct { ownership []pipelineFieldOwnership artifactFamilies map[string]ScriptoriumArtifactFamilyConfig artifactFamiliesExpanded bool + publishDeclared bool } type pipelineProfileSelection struct {