Expand canonical party artifact families

This commit is contained in:
2026-08-30 14:15:57 +00:00
parent c4435b76c4
commit fb9a4d14f4
10 changed files with 616 additions and 15 deletions

View File

@@ -0,0 +1,254 @@
package config
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
)
const (
partyCharactersFamilySource = "party.characters"
characterIDToken = "{character_id}"
)
var memberVariableSelectors = map[string]func(PartyCharacter) string{
"character_id": func(character PartyCharacter) string { return character.ID },
"player.name": func(character PartyCharacter) string { return character.Player.Name },
"character.name": func(character PartyCharacter) string { return character.Character.Name },
"character.class_summary": func(character PartyCharacter) string { return character.ClassSummary() },
"character.alias_summary": func(character PartyCharacter) string { return character.AliasSummary() },
}
func retainArtifactFamilyDeclarations(cfg *PipelineConfig) {
if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil {
return
}
cfg.resolution.artifactFamilies = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies)
}
func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) error {
if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil || cfg.resolution.artifactFamiliesExpanded {
return nil
}
families := cfg.resolution.artifactFamilies
if len(families) == 0 {
families = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies)
}
if len(families) == 0 {
return nil
}
if party.Mode != PartyModeCanonical || party.Canonical == nil {
return fmt.Errorf("pipeline.scriptorium.artifact_families requires a canonical campaign party")
}
expanded := cloneArtifactDefinitions(cfg.Scriptorium.Artifacts)
familyKeys := sortedFamilyKeys(families)
for _, familyKey := range familyKeys {
if !artifactpolicy.IsConfiguredKey(familyKey) {
return fmt.Errorf("pipeline.scriptorium.artifact_families keys must match ^[a-z][a-z0-9_]*$")
}
if _, exists := expanded[familyKey]; exists {
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s collides with configured artifact key %q", familyKey, familyKey)
}
}
characters := append([]PartyCharacter(nil), party.Canonical.Characters...)
sort.Slice(characters, func(left, right int) bool { return characters[left].ID < characters[right].ID })
catalog := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(familyKeys)), Members: map[string]ArtifactFamilyMemberOrigin{}}
outputOwners := make(map[string]string, len(expanded))
for key, artifact := range expanded {
if output := strings.TrimSpace(artifact.OutputPath); output != "" {
outputOwners[output] = key
}
}
for _, familyKey := range familyKeys {
family := families[familyKey]
prefix := "pipeline.scriptorium.artifact_families." + familyKey
if strings.TrimSpace(family.ForEach) != partyCharactersFamilySource {
return fmt.Errorf("%s.for_each must be %q", prefix, partyCharactersFamilySource)
}
if err := validateFamilyOutputPattern(prefix+".output_path_pattern", family.OutputPathPattern); err != nil {
return err
}
familyOrigin := ArtifactFamilyOrigin{
Members: make([]string, 0, len(characters)),
MemberDependencies: append([]string(nil), family.MemberDependencies...),
Publish: cloneArtifactFamilyPublish(family.Publish),
Source: artifactFamilySource(cfg, familyKey),
}
for _, character := range characters {
key := familyKey + "_" + character.ID
if !artifactpolicy.IsConfiguredKey(key) {
return fmt.Errorf("%s generates invalid artifact key %q", prefix, key)
}
if _, exists := expanded[key]; exists {
return fmt.Errorf("%s generates artifact key %q that collides with another artifact", prefix, key)
}
outputPath := strings.ReplaceAll(family.OutputPathPattern, characterIDToken, character.ID)
if prior, exists := outputOwners[outputPath]; exists {
return fmt.Errorf("%s.output_path_pattern generates output path %q already used by artifact %q", prefix, outputPath, prior)
}
vars, err := resolveFamilyVars(prefix, family, character)
if err != nil {
return err
}
expanded[key] = ScriptoriumArtifactConfig{
Enabled: family.Enabled, DependsOn: append([]string(nil), family.DependsOn...), RenderDebug: family.RenderDebug,
PromptID: family.PromptID, ProfileID: family.ProfileID, OutputPath: outputPath, Timeout: family.Timeout,
Inputs: cloneArtifactInputs(family.Inputs), Vars: vars,
}
outputOwners[outputPath] = key
familyOrigin.Members = append(familyOrigin.Members, key)
catalog.Members[key] = ArtifactFamilyMemberOrigin{Family: familyKey, CharacterID: character.ID, Source: familyOrigin.Source}
}
catalog.Families[familyKey] = familyOrigin
}
cfg.Scriptorium.Artifacts = expanded
cfg.Scriptorium.ArtifactFamilies = nil
cfg.familyCatalog = catalog
cfg.resolution.artifactFamiliesExpanded = true
if err := validateScriptorium(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 validateFamilyOutputPattern(field, pattern string) error {
if strings.Count(pattern, characterIDToken) != 1 {
return fmt.Errorf("%s must contain exactly one %s token", field, characterIDToken)
}
remainder := strings.ReplaceAll(pattern, characterIDToken, "")
if strings.ContainsAny(remainder, "{}") {
return fmt.Errorf("%s contains unsupported brace syntax", field)
}
return nil
}
func resolveFamilyVars(prefix string, family ScriptoriumArtifactFamilyConfig, character PartyCharacter) (map[string]any, error) {
vars := make(map[string]any, len(family.Vars)+len(family.MemberVars))
for name, value := range family.Vars {
vars[name] = value
}
for name, selector := range family.MemberVars {
if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("%s.member_vars keys must be non-empty", prefix)
}
if _, exists := vars[name]; exists {
return nil, fmt.Errorf("%s.member_vars.%s conflicts with static vars", prefix, name)
}
resolve, ok := memberVariableSelectors[strings.TrimSpace(selector)]
if !ok {
return nil, fmt.Errorf("%s.member_vars.%s selector %q is unsupported", prefix, name, selector)
}
vars[name] = resolve(character)
}
return vars, nil
}
func sortedFamilyKeys(families map[string]ScriptoriumArtifactFamilyConfig) []string {
keys := make([]string, 0, len(families))
for key := range families {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func artifactFamilySource(cfg *PipelineConfig, family string) string {
if cfg == nil || cfg.resolution == nil {
return ""
}
prefix := "scriptorium.artifact_families." + family + "."
for _, ownership := range cfg.resolution.ownership {
if strings.HasPrefix(ownership.path, prefix) && len(ownership.sources) > 0 {
return ownership.sources[0]
}
}
return pipelineDefaultOwnershipSource
}
func cloneArtifactDefinitions(in map[string]ScriptoriumArtifactConfig) map[string]ScriptoriumArtifactConfig {
if len(in) == 0 {
return map[string]ScriptoriumArtifactConfig{}
}
out := make(map[string]ScriptoriumArtifactConfig, len(in))
for key, artifact := range in {
artifact.DependsOn = append([]string(nil), artifact.DependsOn...)
artifact.Inputs = cloneArtifactInputs(artifact.Inputs)
artifact.Vars = cloneArtifactVars(artifact.Vars)
out[key] = artifact
}
return out
}
func cloneArtifactInputs(in map[string]ScriptoriumInputConfig) map[string]ScriptoriumInputConfig {
if len(in) == 0 {
return nil
}
out := make(map[string]ScriptoriumInputConfig, len(in))
for key, input := range in {
out[key] = input
}
return out
}
func cloneArtifactVars(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneArtifactFamilyDefinitions(in map[string]ScriptoriumArtifactFamilyConfig) map[string]ScriptoriumArtifactFamilyConfig {
if len(in) == 0 {
return nil
}
out := make(map[string]ScriptoriumArtifactFamilyConfig, len(in))
for key, family := range in {
family.DependsOn = append([]string(nil), family.DependsOn...)
family.MemberDependencies = append([]string(nil), family.MemberDependencies...)
family.Inputs = cloneArtifactInputs(family.Inputs)
family.Vars = cloneArtifactVars(family.Vars)
if len(family.MemberVars) > 0 {
memberVars := make(map[string]string, len(family.MemberVars))
for name, selector := range family.MemberVars {
memberVars[name] = selector
}
family.MemberVars = memberVars
}
family.Publish = cloneArtifactFamilyPublish(family.Publish)
out[key] = family
}
return out
}
func cloneArtifactFamilyPublish(in *ScriptoriumArtifactFamilyPublishConfig) *ScriptoriumArtifactFamilyPublishConfig {
if in == nil {
return nil
}
copy := *in
return &copy
}
func cloneArtifactFamilyCatalog(in ArtifactFamilyCatalog) ArtifactFamilyCatalog {
out := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(in.Families)), Members: make(map[string]ArtifactFamilyMemberOrigin, len(in.Members))}
for key, family := range in.Families {
family.Members = append([]string(nil), family.Members...)
family.MemberDependencies = append([]string(nil), family.MemberDependencies...)
family.Publish = cloneArtifactFamilyPublish(family.Publish)
out.Families[key] = family
}
for key, member := range in.Members {
out.Members[key] = member
}
return out
}

View File

@@ -0,0 +1,227 @@
package config
import (
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
func TestArtifactFamiliesExpandCanonicalCharactersDeterministically(t *testing.T) {
dir := t.TempDir()
writePartyResolutionFile(t, dir+"/party.yml", `schema_version: narratio.party.v1
characters:
zeta:
player: {name: Zara}
character:
name: Zeta
alias: [Z]
classes: [{name: wizard, level: 8}, {name: fighter}]
alpha:
player: {name: Ada}
character:
name: Alpha
alias: [A, The First]
classes: [{name: ranger}]
`)
pipelinePath, campaignPath, sessionPath := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(`
character_items:
enabled: false
for_each: party.characters
prompt_id: dnd.character_items
profile_id: production
output_path_pattern: artifacts/characters/{character_id}/items.md
inputs:
transcript: {source: narratio.transcript.final_trimmed, required: true}
member_vars:
id: character_id
player: player.name
name: character.name
classes: character.class_summary
aliases: character.alias_summary
vars:
static_flag: true
character_meta:
enabled: true
for_each: party.characters
prompt_id: dnd.character_meta
profile_id: production
output_path_pattern: artifacts/characters/{character_id}/meta.md
inputs:
transcript: {source: narratio.transcript.final_trimmed, required: true}
`))
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
if len(cfg.Pipeline.Scriptorium.ArtifactFamilies) != 0 {
t.Fatalf("adapter-facing artifact families = %#v, want none", cfg.Pipeline.Scriptorium.ArtifactFamilies)
}
keys := sortedArtifactKeys(cfg.Pipeline.Scriptorium.Artifacts)
wantKeys := []string{"character_items_alpha", "character_items_zeta", "character_meta_alpha", "character_meta_zeta"}
if !reflect.DeepEqual(keys, wantKeys) {
t.Fatalf("artifact keys = %#v, want %#v", keys, wantKeys)
}
alpha := cfg.Pipeline.Scriptorium.Artifacts["character_items_alpha"]
if alpha.Enabled || alpha.OutputPath != "artifacts/characters/alpha/items.md" {
t.Fatalf("alpha artifact = %#v", alpha)
}
if !reflect.DeepEqual(alpha.Vars, map[string]any{
"static_flag": true, "id": "alpha", "player": "Ada", "name": "Alpha", "classes": "ranger", "aliases": "A, The First",
}) {
t.Fatalf("alpha vars = %#v", alpha.Vars)
}
zeta := cfg.Pipeline.Scriptorium.Artifacts["character_items_zeta"]
if got := zeta.Vars["classes"]; got != "wizard 8 / fighter" {
t.Fatalf("zeta class summary = %#v", got)
}
catalog := ArtifactFamilies(cfg.Pipeline)
if got := catalog.Families["character_meta"].Members; !reflect.DeepEqual(got, []string{"character_meta_alpha", "character_meta_zeta"}) {
t.Fatalf("meta members = %#v", got)
}
if got := catalog.Members["character_items_zeta"]; got.Family != "character_items" || got.CharacterID != "zeta" || got.Source == "" {
t.Fatalf("zeta origin = %#v", got)
}
firstDigest := EffectivePipelineDigest(cfg.Pipeline)
writePartyResolutionFile(t, dir+"/party.yml", `schema_version: narratio.party.v1
characters:
alpha:
player: {name: Ada}
character:
name: Alpha
alias: [A, The First]
classes: [{name: ranger}]
zeta:
player: {name: Zara}
character:
name: Zeta
alias: [Z]
classes: [{name: wizard, level: 8}, {name: fighter}]
`)
again, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("reordered LoadWithSessionOptions() error = %v", err)
}
if !reflect.DeepEqual(cfg.Pipeline.Scriptorium.Artifacts, again.Pipeline.Scriptorium.Artifacts) || !reflect.DeepEqual(ArtifactFamilies(cfg.Pipeline), ArtifactFamilies(again.Pipeline)) || firstDigest != EffectivePipelineDigest(again.Pipeline) {
t.Fatalf("reordered source maps changed expansion")
}
}
func TestArtifactFamiliesRejectInvalidExpansionDeclarations(t *testing.T) {
tests := []struct {
name string
party string
families string
wantError string
}{
{name: "legacy party", party: "legacy: party\n", families: validFamilyYAML, wantError: "requires a canonical campaign party"},
{name: "invalid iteration", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "party.characters", "party.players", 1), wantError: ".for_each must be"},
{name: "invalid token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "{character_id}", "{character_name}", 1), wantError: "must contain exactly one"},
{name: "repeated token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "meta.md", "{character_id}.md", 1), wantError: "must contain exactly one"},
{name: "member selector", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "character.name", "character.unknown", 1), wantError: "selector"},
{name: "static variable collision", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "member_vars:\n character_name", "vars:\n character_name: static\n member_vars:\n character_name", 1), wantError: "conflicts with static vars"},
{name: "unsafe output", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "artifacts/characters/{character_id}/meta.md", "../{character_id}.md", 1), wantError: "must not contain path traversal"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
writePartyResolutionFile(t, dir+"/party.yml", test.party)
pipelinePath, campaignPath, sessionPath := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(test.families))
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("LoadWithSessionOptions() error = %v, want %q", err, test.wantError)
}
})
}
}
func TestArtifactFamilyStrictDecodeRejectsUnknownFields(t *testing.T) {
dir := t.TempDir()
pipelinePath, _, _ := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(validFamilyYAML+" unsupported: true\n"))
_, err := LoadPipeline(pipelinePath)
if err == nil || !strings.Contains(err.Error(), "field unsupported not found") {
t.Fatalf("LoadPipeline() error = %v", err)
}
}
func TestArtifactFamiliesRejectGeneratedKeyAndOutputCollisions(t *testing.T) {
document, err := ParseParty([]byte(canonicalPartyFixture))
if err != nil {
t.Fatal(err)
}
base := ScriptoriumArtifactFamilyConfig{
ForEach: partyCharactersFamilySource, PromptID: "dnd.character_meta",
OutputPathPattern: "artifacts/characters/{character_id}/meta.md",
}
for _, test := range []struct {
name string
artifacts map[string]ScriptoriumArtifactConfig
wantError string
}{
{
name: "generated key", artifacts: map[string]ScriptoriumArtifactConfig{
"character_meta_arannis": {OutputPath: "artifacts/other.md"},
}, wantError: "generates artifact key",
},
{
name: "generated output", artifacts: map[string]ScriptoriumArtifactConfig{
"existing": {OutputPath: "artifacts/characters/arannis/meta.md"},
}, wantError: "generates output path",
},
} {
t.Run(test.name, func(t *testing.T) {
pipeline := &PipelineConfig{
Scriptorium: &ScriptoriumConfig{Artifacts: test.artifacts},
resolution: &pipelineResolutionMetadata{artifactFamilies: map[string]ScriptoriumArtifactFamilyConfig{"character_meta": base}},
}
err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical})
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("expandPipelineArtifactFamilies() error = %v, want %q", err, test.wantError)
}
})
}
}
const validFamilyYAML = `
character_meta:
enabled: true
for_each: party.characters
prompt_id: dnd.character_meta
profile_id: production
output_path_pattern: artifacts/characters/{character_id}/meta.md
member_vars:
character_name: character.name
`
func artifactFamilyPipelineYAML(families string) string {
lines := strings.Split(strings.TrimPrefix(families, "\n"), "\n")
for index, line := range lines {
if line != "" {
lines[index] = " " + line
}
}
return "workspace:\n root: /tmp/narratio-work\nwhisperx:\n transcribe_url: https://example.test/transcribe\nnotification:\n mode: noop\nscriptorium:\n binary: scriptorium\n artifact_families:\n" + strings.Join(lines, "\n")
}
func writeArtifactFamilyConfig(t *testing.T, dir, pipeline string) (string, string, string) {
t.Helper()
pipelinePath := writePartyResolutionFile(t, filepath.Join(dir, "pipeline.yml"), pipeline)
campaignPath := writePartyResolutionFile(t, filepath.Join(dir, "campaign.yml"), "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n party_file: party.yml\n")
sessionPath := writePartyResolutionFile(t, filepath.Join(dir, "session.yml"), "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n")
return pipelinePath, campaignPath, sessionPath
}
func sortedArtifactKeys(artifacts map[string]ScriptoriumArtifactConfig) []string {
keys := make([]string, 0, len(artifacts))
for key := range artifacts {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}

View File

@@ -34,7 +34,16 @@ type PipelineConfig struct {
Notarius *NotariusConfig `yaml:"notarius"`
Notification NotificationConfig `yaml:"notification"`
resolution *pipelineResolutionMetadata `yaml:"-"`
resolution *pipelineResolutionMetadata `yaml:"-"`
familyCatalog ArtifactFamilyCatalog `yaml:"-"`
}
// ArtifactFamilies returns a copy of runtime-only expansion provenance.
func ArtifactFamilies(cfg *PipelineConfig) ArtifactFamilyCatalog {
if cfg == nil {
return ArtifactFamilyCatalog{}
}
return cloneArtifactFamilyCatalog(cfg.familyCatalog)
}
// CampaignsConfig configures the local campaign registry.
@@ -236,11 +245,12 @@ type RenderConfig struct {
// ScriptoriumConfig configures Scriptorium-backed artifact generation.
type ScriptoriumConfig struct {
Binary string `yaml:"binary"`
ConfigPath string `yaml:"config_path"`
Timeout string `yaml:"timeout"`
RenderDebug bool `yaml:"render_debug"`
Artifacts map[string]ScriptoriumArtifactConfig `yaml:"artifacts"`
Binary string `yaml:"binary"`
ConfigPath string `yaml:"config_path"`
Timeout string `yaml:"timeout"`
RenderDebug bool `yaml:"render_debug"`
Artifacts map[string]ScriptoriumArtifactConfig `yaml:"artifacts"`
ArtifactFamilies map[string]ScriptoriumArtifactFamilyConfig `yaml:"artifact_families"`
}
// ScriptoriumArtifactConfig configures one named output artifact workflow.
@@ -256,6 +266,55 @@ type ScriptoriumArtifactConfig struct {
Vars map[string]any `yaml:"vars"`
}
// ScriptoriumArtifactFamilyConfig declares the shared configuration expanded
// into one ordinary artifact for each canonical party character.
type ScriptoriumArtifactFamilyConfig struct {
Enabled bool `yaml:"enabled"`
ForEach string `yaml:"for_each"`
DependsOn []string `yaml:"depends_on"`
RenderDebug *bool `yaml:"render_debug"`
PromptID string `yaml:"prompt_id"`
ProfileID string `yaml:"profile_id"`
OutputPathPattern string `yaml:"output_path_pattern"`
Timeout string `yaml:"timeout"`
Inputs map[string]ScriptoriumInputConfig `yaml:"inputs"`
Vars map[string]any `yaml:"vars"`
MemberVars map[string]string `yaml:"member_vars"`
MemberDependencies []string `yaml:"member_dependencies"`
Publish *ScriptoriumArtifactFamilyPublishConfig `yaml:"publish"`
}
// ScriptoriumArtifactFamilyPublishConfig retains the typed declaration for
// family publishing. It is resolved by the publish owner.
type ScriptoriumArtifactFamilyPublishConfig struct {
Enabled bool `yaml:"enabled"`
Required bool `yaml:"required"`
DestPattern string `yaml:"dest_pattern"`
}
// ArtifactFamilyCatalog records the runtime-only origin of expanded artifacts.
// It is intentionally kept outside the Scriptorium adapter configuration.
type ArtifactFamilyCatalog struct {
Families map[string]ArtifactFamilyOrigin
Members map[string]ArtifactFamilyMemberOrigin
}
// ArtifactFamilyOrigin records one declared family and its source ownership.
type ArtifactFamilyOrigin struct {
Members []string
MemberDependencies []string
Publish *ScriptoriumArtifactFamilyPublishConfig
Source string
}
// ArtifactFamilyMemberOrigin identifies the canonical party member that
// produced one ordinary concrete artifact.
type ArtifactFamilyMemberOrigin struct {
Family string
CharacterID string
Source string
}
// ScriptoriumInputConfig configures one named prompt input source.
type ScriptoriumInputConfig struct {
Source string `yaml:"source"`

View File

@@ -38,6 +38,7 @@ func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineCo
if err := finalizePipelineResolution(cfg); err != nil {
return nil, fmt.Errorf("load pipeline config: %w", err)
}
retainArtifactFamilyDeclarations(cfg)
return cfg, nil
}
@@ -204,6 +205,9 @@ func LoadPipelineCampaign(pipelinePath string, pipeline *PipelineConfig, campaig
return LoadedPipelineCampaign{}, err
}
}
if err := expandPipelineArtifactFamilies(pipeline, party); err != nil {
return LoadedPipelineCampaign{}, err
}
return LoadedPipelineCampaign{
PipelinePath: pipelinePath,
Pipeline: pipeline,

View File

@@ -13,12 +13,14 @@ import (
)
type pipelineResolutionMetadata struct {
rootPath string
imports []string
sources []string
selectedProfile *pipelineProfileSelection
effectiveDigest string
ownership []pipelineFieldOwnership
rootPath string
imports []string
sources []string
selectedProfile *pipelineProfileSelection
effectiveDigest string
ownership []pipelineFieldOwnership
artifactFamilies map[string]ScriptoriumArtifactFamilyConfig
artifactFamiliesExpanded bool
}
type pipelineProfileSelection struct {

View File

@@ -671,6 +671,7 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error
configuredArtifacts := make(map[string]struct{}, len(cfg.Artifacts))
referencedArtifacts := make(map[string]struct{})
outputOwners := make(map[string]string, len(cfg.Artifacts))
for artifactName := range cfg.Artifacts {
if !artifactpolicy.IsConfiguredKey(artifactName) {
return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$")
@@ -693,6 +694,10 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error
if err := validatePathWithinRoot(pathField, artifactCfg.OutputPath, DefaultScriptoriumArtifactOutputRoot); err != nil {
return err
}
if previous, exists := outputOwners[artifactCfg.OutputPath]; exists {
return fmt.Errorf("%s.output_path %q duplicates artifact %q", pathField, artifactCfg.OutputPath, previous)
}
outputOwners[artifactCfg.OutputPath] = artifactName
}
if err := validateDuration("pipeline.scriptorium.artifacts."+artifactName+".timeout", artifactCfg.Timeout); err != nil {
return err