Implemented default config/campaign discovery for narratio session init

This commit is contained in:
2026-05-22 11:36:57 -05:00
parent 2aa074c5cf
commit d0936fb022
5 changed files with 240 additions and 53 deletions

View File

@@ -148,8 +148,8 @@ Valid stage names:
### `session init` ### `session init`
- `--config <path>`: required. - `--config <path>`: optional explicit `pipeline.yml` path.
- `--campaign <path>`: required. - `--campaign <path>`: optional explicit `campaign.yml` path.
- `--session-id <value>`: required. - `--session-id <value>`: required.
- `--output <path>`: local `session.yml` target; mutually exclusive with `--remote`. - `--output <path>`: local `session.yml` target; mutually exclusive with `--remote`.
- `--remote`: write remote `session.yml` to the canonical session prefix; mutually exclusive with `--output`. - `--remote`: write remote `session.yml` to the canonical session prefix; mutually exclusive with `--output`.
@@ -302,12 +302,14 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio session init --config <pipeline.yml> --campaign <campaign.yml> --session-id <id> --output ./session.yml narratio session init --session-id <id> --output ./session.yml
narratio session init --session-id <id> --remote
narratio session init --config <pipeline.yml> --campaign <campaign.yml> --session-id <id> --remote narratio session init --config <pipeline.yml> --campaign <campaign.yml> --session-id <id> --remote
``` ```
Behavior: Behavior:
- exactly one of `--output` or `--remote` is required. - exactly one of `--output` or `--remote` is required.
- `--config` and `--campaign` are optional overrides; omitted values use normal default config discovery.
- remote writes target `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. - remote writes target `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`.
- existing local or remote targets fail unless `--force` is passed. - existing local or remote targets fail unless `--force` is passed.
- remote writes use existence checks, not compare-and-swap. - remote writes use existence checks, not compare-and-swap.

View File

@@ -26,10 +26,10 @@ Notes:
Initialize a remote session skeleton: Initialize a remote session skeleton:
```bash ```bash
narratio session init --config /etc/narratio/pipeline.yml --campaign /etc/narratio/campaign.yml --session-id 2026-04-04 --remote narratio session init --session-id 2026-04-04 --remote
``` ```
Remote init writes `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. It fails if the object already exists unless `--force` is passed. Remote init uses normal default config discovery and writes `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. Pass `--config` and `--campaign` when testing non-system config files. It fails if the object already exists unless `--force` is passed.
Validate before running: Validate before running:

View File

@@ -12,18 +12,21 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
type pipelineCampaignConfig struct {
PipelinePath string
CampaignPath string
Pipeline *config.PipelineConfig
Campaign *config.CampaignConfig
}
func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) { func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) {
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag) base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag)
if err != nil {
return nil, err
}
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignFlag)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" { if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" {
return config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, explicitSession, sessionOpts) return config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, explicitSession, sessionOpts)
} }
discoveredSession, err := discoverSessionConfigPathWithCandidates(config.DefaultSessionConfigSearchPaths) discoveredSession, err := discoverSessionConfigPathWithCandidates(config.DefaultSessionConfigSearchPaths)
@@ -31,16 +34,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
return nil, err return nil, err
} }
if discoveredSession.Path != "" { if discoveredSession.Path != "" {
return config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, discoveredSession.Path, sessionOpts) return config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, discoveredSession.Path, sessionOpts)
}
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
if err != nil {
return nil, err
}
campaignCfg, err := config.LoadCampaign(resolvedCampaignPath)
if err != nil {
return nil, err
} }
sessionID := strings.TrimSpace(sessionOpts.SessionID) sessionID := strings.TrimSpace(sessionOpts.SessionID)
@@ -48,13 +42,13 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
return nil, missingSessionConfigError(discoveredSession.Searched, "remote session loading requires --session-id") return nil, missingSessionConfigError(discoveredSession.Searched, "remote session loading requires --session-id")
} }
sessionPrefix := artifacts.S3SessionPrefix(pipelineCfg.Storage.S3.RootPrefix, campaignCfg.Campaign, sessionID) sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, base.Campaign.Campaign, sessionID)
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix) remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
partialCfg := &config.Config{ partialCfg := &config.Config{
Pipeline: pipelineCfg, Pipeline: base.Pipeline,
Campaign: campaignCfg, Campaign: base.Campaign,
PipelinePath: resolvedPipelinePath, PipelinePath: base.PipelinePath,
CampaignPath: resolvedCampaignPath, CampaignPath: base.CampaignPath,
} }
store, err := newCommandObjectStore(ctx, partialCfg, nil) store, err := newCommandObjectStore(ctx, partialCfg, nil)
if err != nil { if err != nil {
@@ -73,22 +67,22 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
if err != nil { if err != nil {
return nil, fmt.Errorf("read downloaded remote session %q: %w", sessionTempPath, err) return nil, fmt.Errorf("read downloaded remote session %q: %w", sessionTempPath, err)
} }
sessionCfg, err := config.LoadSessionBytesWithOptions("s3://"+s3BucketName(pipelineCfg)+"/"+remoteKey, sessionBytes, sessionOpts) sessionCfg, err := config.LoadSessionBytesWithOptions("s3://"+s3BucketName(base.Pipeline)+"/"+remoteKey, sessionBytes, sessionOpts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return config.Resolve( return config.Resolve(
resolvedPipelinePath, base.PipelinePath,
pipelineCfg, base.Pipeline,
resolvedCampaignPath, base.CampaignPath,
campaignCfg, base.Campaign,
sessionTempPath, sessionTempPath,
sessionCfg, sessionCfg,
config.SessionSource{ config.SessionSource{
Source: "session_config.s3", Source: "session_config.s3",
LocalPath: sessionTempPath, LocalPath: sessionTempPath,
S3Bucket: s3BucketName(pipelineCfg), S3Bucket: s3BucketName(base.Pipeline),
S3Key: remoteKey, S3Key: remoteKey,
S3Size: sessionInfo.Size, S3Size: sessionInfo.Size,
S3ETag: sessionInfo.ETag, S3ETag: sessionInfo.ETag,
@@ -97,6 +91,31 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
) )
} }
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag string) (*pipelineCampaignConfig, error) {
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
if err != nil {
return nil, err
}
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignFlag)
if err != nil {
return nil, err
}
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
if err != nil {
return nil, err
}
campaignCfg, err := config.LoadCampaign(resolvedCampaignPath)
if err != nil {
return nil, err
}
return &pipelineCampaignConfig{
PipelinePath: resolvedPipelinePath,
CampaignPath: resolvedCampaignPath,
Pipeline: pipelineCfg,
Campaign: campaignCfg,
}, nil
}
func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) { func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) {
objects, err := store.List(ctx, sessionPrefix) objects, err := store.List(ctx, sessionPrefix)
if err != nil { if err != nil {

View File

@@ -258,8 +258,8 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath, campaignPath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string var pipelinePath, campaignPath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
var remote, force bool var remote, force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml") fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionID, "session-id", "", "session identifier") fs.StringVar(&sessionID, "session-id", "", "session identifier")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier")
fs.StringVar(&date, "date", "", "session date") fs.StringVar(&date, "date", "", "session date")
@@ -275,8 +275,8 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
if fs.NArg() != 0 { if fs.NArg() != 0 {
return fmt.Errorf("session init: unexpected positional arguments") return fmt.Errorf("session init: unexpected positional arguments")
} }
if strings.TrimSpace(pipelinePath) == "" || strings.TrimSpace(campaignPath) == "" || strings.TrimSpace(sessionID) == "" { if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("session init: --config, --campaign, and --session-id are required") return fmt.Errorf("session init: --session-id is required")
} }
if (strings.TrimSpace(output) == "") == !remote { if (strings.TrimSpace(output) == "") == !remote {
return fmt.Errorf("session init: specify exactly one target: --output <path> or --remote") return fmt.Errorf("session init: specify exactly one target: --output <path> or --remote")
@@ -285,24 +285,12 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive") return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
} }
resolvedPipeline, err := resolvePipelineConfigPath(pipelinePath) base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath)
if err != nil {
return fmt.Errorf("session init: %w", err)
}
resolvedCampaign, err := resolveCampaignConfigPath(campaignPath)
if err != nil {
return fmt.Errorf("session init: %w", err)
}
pipelineCfg, err := config.LoadPipeline(resolvedPipeline)
if err != nil {
return fmt.Errorf("session init: %w", err)
}
campaignCfg, err := config.LoadCampaign(resolvedCampaign)
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
data, err := buildSessionYAML(campaignCfg.Campaign, sessionID, previousSessionID, date, title, audioS3Prefix, audioDir) data, err := buildSessionYAML(base.Campaign.Campaign, sessionID, previousSessionID, date, title, audioS3Prefix, audioDir)
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
@@ -317,7 +305,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
cfg, err := config.Resolve(resolvedPipeline, pipelineCfg, resolvedCampaign, campaignCfg, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label}) cfg, err := config.Resolve(base.PipelinePath, base.Pipeline, base.CampaignPath, base.Campaign, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
@@ -337,7 +325,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
sessionPrefix := artifacts.S3SessionPrefix(pipelineCfg.Storage.S3.RootPrefix, campaignCfg.Campaign, sessionID) sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, base.Campaign.Campaign, sessionID)
key := artifacts.S3SessionConfigKey(sessionPrefix) key := artifacts.S3SessionConfigKey(sessionPrefix)
exists, err := store.Exists(ctx, key) exists, err := store.Exists(ctx, key)
if err != nil { if err != nil {
@@ -362,7 +350,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
if _, err := store.Upload(ctx, tmpPath, key, storage.UploadOptions{ContentType: "application/x-yaml; charset=utf-8"}); err != nil { if _, err := store.Upload(ctx, tmpPath, key, storage.UploadOptions{ContentType: "application/x-yaml; charset=utf-8"}); err != nil {
return fmt.Errorf("session init: upload remote session %q: %w", key, err) return fmt.Errorf("session init: upload remote session %q: %w", key, err)
} }
_, err = fmt.Fprintf(out, "narratio session init: wrote s3://%s/%s\n", s3BucketName(pipelineCfg), key) _, err = fmt.Fprintf(out, "narratio session init: wrote s3://%s/%s\n", s3BucketName(base.Pipeline), key)
return err return err
} }

View File

@@ -48,6 +48,172 @@ func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) {
} }
} }
func TestExecuteSessionInitRemoteUsesDefaultConfigDiscovery(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--session-id", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
key := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07"))
if _, ok := fake.Objects[key]; !ok {
t.Fatalf("remote session key %q not uploaded; objects=%v", key, fake.Objects)
}
if storeInitCalls != 1 {
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
}
}
func TestExecuteSessionInitLocalUsesDefaultConfigDiscovery(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
outputPath := filepath.Join(t.TempDir(), "session.yml")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--session-id", "2026-06-07",
"--output", outputPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read generated session: %v", err)
}
if !strings.Contains(string(data), `session_id: "2026-06-07"`) || !strings.Contains(string(data), "prefix: audio/") {
t.Fatalf("generated session = %q", string(data))
}
}
func TestExecuteSessionInitExplicitConfigWinsOverDefaults(t *testing.T) {
workspaceRoot := t.TempDir()
defaultPipeline, defaultCampaign, _ := writeValidConfigFiles(t, workspaceRoot)
withDefaultPipelineCampaignConfigs(t, defaultPipeline, defaultCampaign)
explicitDir := t.TempDir()
explicitCampaign := filepath.Join(explicitDir, "campaign.yml")
if err := os.WriteFile(explicitCampaign, []byte(`campaign: explicit-campaign
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`), 0o644); err != nil {
t.Fatalf("write explicit campaign: %v", err)
}
mustWriteTestFile(t, filepath.Join(explicitDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n")
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--config", defaultPipeline,
"--campaign", explicitCampaign,
"--session-id", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
explicitKey := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "explicit-campaign", "2026-06-07"))
if _, ok := fake.Objects[explicitKey]; !ok {
t.Fatalf("explicit campaign remote key %q not uploaded; objects=%v", explicitKey, fake.Objects)
}
defaultKey := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07"))
if _, ok := fake.Objects[defaultKey]; ok {
t.Fatalf("default campaign key %q uploaded despite explicit campaign override", defaultKey)
}
}
func TestExecuteSessionInitRequiresSessionID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "init", "--remote"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "session init: --session-id is required") {
t.Fatalf("stderr = %q, want session-id required error", stderr.String())
}
}
func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T) {
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
origCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")}
config.DefaultCampaignConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-campaign.yml")}
t.Cleanup(func() {
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
config.DefaultCampaignConfigSearchPaths = origCampaignDefaults
})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "init", "--session-id", "2026-06-07", "--remote"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "session init: no pipeline config path provided and no default pipeline config found; searched:") {
t.Fatalf("stderr = %q, want default pipeline searched-path error", stderr.String())
}
}
func TestExecuteSessionInitRemoteLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
accessKeyEnv := "NARRATIO_TEST_SESSION_INIT_OBJECT_KEY_ID"
secretKeyEnv := "NARRATIO_TEST_SESSION_INIT_OBJECT_SECRET"
restoreEnvAfterTest(t, accessKeyEnv, secretKeyEnv)
secretsDir := t.TempDir()
mustWriteTestFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-key-id\n")
mustWriteTestFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret\n")
addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv)
fake := &storage.FakeBackend{}
origStoreFn := newObjectStoreFromConfigFn
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
if os.Getenv(accessKeyEnv) != "test-key-id" || os.Getenv(secretKeyEnv) != "test-secret" {
return nil, fmt.Errorf("secrets were not loaded before object store init")
}
return fake, nil
}
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "init", "--session-id", "2026-06-07", "--remote"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
}
func TestExecuteSessionValidateLoadsSecretsBeforeObjectStoreInit(t *testing.T) { func TestExecuteSessionValidateLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -308,6 +474,18 @@ func TestExecuteTopLevelLockAndUnlockAreRemoved(t *testing.T) {
} }
} }
func withDefaultPipelineCampaignConfigs(t *testing.T, pipelinePath, campaignPath string) {
t.Helper()
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
origCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
config.DefaultCampaignConfigSearchPaths = []string{campaignPath}
t.Cleanup(func() {
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
config.DefaultCampaignConfigSearchPaths = origCampaignDefaults
})
}
func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) { func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)