From 07f1eb21485a9dba6390e92f937af1d0122a826e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 2 Jun 2026 18:43:47 +0000 Subject: [PATCH] Centralize publish output projections --- docs/internal/publish.md | 2 + internal/app/run.go | 28 ++++++------- internal/app/run_test.go | 69 +++++++++++++++++++++++++++++++++ internal/publish/execute.go | 14 ++----- internal/publish/output.go | 40 +++++++++++++------ internal/publish/output_test.go | 47 ++++++++++++++++++++++ 6 files changed, 164 insertions(+), 36 deletions(-) diff --git a/docs/internal/publish.md b/docs/internal/publish.md index dc7f5e0..12e55c3 100644 --- a/docs/internal/publish.md +++ b/docs/internal/publish.md @@ -26,6 +26,8 @@ The package publishes source files and Markdown-to-HTML outputs. Markdown sideca The package uses `internal/state` for destination comparison, `internal/storage` for IO, and the shared `internal/config` publish/transform policy helper for request validation. It resolves transforms through a narrow resolver supplied by the caller; concrete transform registration is owned by the app layer. It does not parse CLI flags, load config files, or choose which source bundles a destination receives. +The package owns projection from planned publish outputs to destination state output records and managed destination output paths. App JSON results and notification events keep their own schemas, but may use the publish output projection to avoid field-mapping drift. + The app layer computes the destination bundle path before planning. `preserve_relative` destinations pass the source-root-relative bundle path. `fixed` destinations pass an empty destination bundle path, which means the destination backend root, and pass only the newest selected source bundle for that destination. When link config is present, publish planning builds per-output URLs from `links.base_url`, the destination bundle path, and each output path. `index.html` outputs use directory-style URLs. The primary URL is selected from planned outputs according to the destination primary policy. diff --git a/internal/app/run.go b/internal/app/run.go index f08f583..70d3bce 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -434,13 +434,14 @@ func shouldNotify(action publish.Action) bool { func notifyEvent(plan publish.Plan) notify.Event { outputs := make([]notify.Output, 0, len(plan.Outputs)) for _, output := range plan.Outputs { + stateOutput := output.StateOutputFile() outputs = append(outputs, notify.Output{ - Path: output.DestinationPath, - Kind: output.Kind, - SourcePath: output.SourcePath, - Transform: output.Transform, - SHA256: output.SHA256, - Size: output.Size, + Path: stateOutput.Path, + Kind: stateOutput.Kind, + SourcePath: stateOutput.SourcePath, + Transform: stateOutput.Transform, + SHA256: stateOutput.SHA256, + Size: stateOutput.Size, }) } return notify.Event{ @@ -542,14 +543,15 @@ func errorAction(pipelineID, destinationID, backend, bundlePath string, err erro func runOutputsFromPlan(outputs []publish.Output) []runOutputResult { results := make([]runOutputResult, 0, len(outputs)) for _, output := range outputs { + stateOutput := output.StateOutputFile() results = append(results, runOutputResult{ - Path: output.DestinationPath, - Kind: output.Kind, - SourcePath: output.SourcePath, - Transform: output.Transform, - URL: output.URL, - SHA256: output.SHA256, - Size: output.Size, + Path: stateOutput.Path, + Kind: stateOutput.Kind, + SourcePath: stateOutput.SourcePath, + Transform: stateOutput.Transform, + URL: stateOutput.URL, + SHA256: stateOutput.SHA256, + Size: stateOutput.Size, }) } return results diff --git a/internal/app/run_test.go b/internal/app/run_test.go index b97bd63..d7ce9f0 100644 --- a/internal/app/run_test.go +++ b/internal/app/run_test.go @@ -622,6 +622,32 @@ func TestRunNotifiesAfterPublication(t *testing.T) { } } +func TestRunNotifiesGeneratedOutputMetadata(t *testing.T) { + sourceRoot := t.TempDir() + destinationRoot := t.TempDir() + writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) + notifier := &recordingNotifier{} + + err := Run(context.Background(), RunOptions{ + ConfigPath: writeLocalConfigWithPolicy(t, sourceRoot, destinationRoot, false, true), + Notifier: notifier, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if got, want := len(notifier.events), 1; got != want { + t.Fatalf("notification count = %d, want %d", got, want) + } + outputs := notifier.events[0].Outputs + if got, want := len(outputs), 1; got != want { + t.Fatalf("notification output count = %d, want %d", got, want) + } + output := outputs[0] + if output.Path != "report.html" || output.Kind != state.OutputKindGenerated || output.SourcePath != "report.md" || output.Transform != "markdown_to_html" || output.SHA256 == "" || output.Size <= 0 { + t.Fatalf("notification output = %#v", output) + } +} + func TestRunNotifiesAfterReplacement(t *testing.T) { sourceRoot := t.TempDir() destinationRoot := t.TempDir() @@ -649,6 +675,49 @@ func TestRunNotifiesAfterReplacement(t *testing.T) { } } +func TestRunJSONIncludesGeneratedOutputMetadata(t *testing.T) { + sourceRoot := t.TempDir() + destinationRoot := t.TempDir() + writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) + + var stdout bytes.Buffer + err := Run(context.Background(), RunOptions{ + ConfigPath: writeLocalConfigWithLinks(t, sourceRoot, destinationRoot, config.PathMappingFixed, "https://reports.example.com/latest", config.LinkPrimaryAuto, false, true, config.TransformModeIndex), + DryRun: true, + Stdout: &stdout, + OutputFormat: OutputFormatJSON, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + result := decodeAppResult(t, stdout.String()) + actions, ok := result["actions"].([]any) + if !ok || len(actions) != 1 { + t.Fatalf("actions = %#v, want one action", result["actions"]) + } + action, ok := actions[0].(map[string]any) + if !ok { + t.Fatalf("action = %#v, want object", actions[0]) + } + if action["primary_url"] != "https://reports.example.com/latest/" { + t.Fatalf("action primary_url = %#v", action["primary_url"]) + } + outputs, ok := action["outputs"].([]any) + if !ok || len(outputs) != 1 { + t.Fatalf("outputs = %#v, want one output", action["outputs"]) + } + output, ok := outputs[0].(map[string]any) + if !ok { + t.Fatalf("output = %#v, want object", outputs[0]) + } + if output["path"] != "index.html" || output["kind"] != state.OutputKindGenerated || output["source_path"] != "report.md" || output["transform"] != "markdown_to_html" || output["url"] != "https://reports.example.com/latest/" { + t.Fatalf("output = %#v, want generated index metadata", output) + } + if output["sha256"] == "" || output["size"] == nil { + t.Fatalf("output = %#v, want digest and size", output) + } +} + func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) { sourceRoot := t.TempDir() destinationRoot := t.TempDir() diff --git a/internal/publish/execute.go b/internal/publish/execute.go index 4d8aa88..a052bc4 100644 --- a/internal/publish/execute.go +++ b/internal/publish/execute.go @@ -23,7 +23,7 @@ func Execute(ctx context.Context, req Request, plan Plan) error { if plan.ExistingState == nil { return fmt.Errorf("replace requires existing destination state") } - if err := req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, existingManagedOutputPaths(*plan.ExistingState), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil { + if err := req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, stateOutputManagedPaths(plan.ExistingState.Outputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil { return err } if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil { @@ -41,7 +41,7 @@ func Execute(ctx context.Context, req Request, plan Plan) error { writtenOutputs := make([]Output, 0, len(plan.Outputs)) cleanup := func() { - _ = req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, managedOutputPaths(writtenOutputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}) + _ = req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, ManagedOutputPaths(writtenOutputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}) } for _, output := range plan.Outputs { destinationPath, err := storage.Join(req.DestinationBundlePath, output.DestinationPath) @@ -76,7 +76,7 @@ func Execute(ctx context.Context, req Request, plan Plan) error { DestinationID: req.DestinationID, PublishedAt: time.Now().UTC(), Source: state.SourceState{Manifest: req.SourceBundle.Manifest}, - Outputs: stateOutputs(plan.Outputs), + Outputs: StateOutputFiles(plan.Outputs), } if plan.PrimaryURL != "" { destinationState.Links = &state.LinkState{PrimaryURL: plan.PrimaryURL} @@ -102,11 +102,3 @@ func Execute(ctx context.Context, req Request, plan Plan) error { } return nil } - -func existingManagedOutputPaths(destinationState state.DistributorState) []string { - paths := make([]string, 0, len(destinationState.Outputs)) - for _, output := range destinationState.Outputs { - paths = append(paths, output.Path) - } - return paths -} diff --git a/internal/publish/output.go b/internal/publish/output.go index d065770..5059318 100644 --- a/internal/publish/output.go +++ b/internal/publish/output.go @@ -97,26 +97,42 @@ func rejectOutputCollisions(outputs []Output) error { return nil } -func stateOutputs(outputs []Output) []state.OutputFile { +func (o Output) StateOutputFile() state.OutputFile { + return state.OutputFile{ + Path: o.DestinationPath, + Kind: o.Kind, + SourcePath: o.SourcePath, + Transform: o.Transform, + URL: o.URL, + SHA256: o.SHA256, + Size: o.Size, + } +} + +func (o Output) ManagedPath() string { + return o.DestinationPath +} + +func StateOutputFiles(outputs []Output) []state.OutputFile { files := make([]state.OutputFile, 0, len(outputs)) for _, output := range outputs { - files = append(files, state.OutputFile{ - Path: output.DestinationPath, - Kind: output.Kind, - SourcePath: output.SourcePath, - Transform: output.Transform, - URL: output.URL, - SHA256: output.SHA256, - Size: output.Size, - }) + files = append(files, output.StateOutputFile()) } return files } -func managedOutputPaths(outputs []Output) []string { +func ManagedOutputPaths(outputs []Output) []string { paths := make([]string, 0, len(outputs)) for _, output := range outputs { - paths = append(paths, output.DestinationPath) + paths = append(paths, output.ManagedPath()) + } + return paths +} + +func stateOutputManagedPaths(outputs []state.OutputFile) []string { + paths := make([]string, 0, len(outputs)) + for _, output := range outputs { + paths = append(paths, output.Path) } return paths } diff --git a/internal/publish/output_test.go b/internal/publish/output_test.go index b0172ff..dc86ad4 100644 --- a/internal/publish/output_test.go +++ b/internal/publish/output_test.go @@ -6,11 +6,58 @@ import ( "gitea.maximumdirect.net/eric/distributor/internal/bundle" "gitea.maximumdirect.net/eric/distributor/internal/config" + "gitea.maximumdirect.net/eric/distributor/internal/state" "gitea.maximumdirect.net/eric/distributor/internal/storage/fake" "gitea.maximumdirect.net/eric/distributor/internal/testutil" "gitea.maximumdirect.net/eric/distributor/internal/transform" ) +func TestOutputStateProjection(t *testing.T) { + sourceOutput := Output{ + SourcePath: "report.md", + DestinationPath: "report.md", + Kind: state.OutputKindSource, + URL: "https://reports.example.com/report.md", + SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Size: 123, + } + sourceState := sourceOutput.StateOutputFile() + if sourceState.Path != "report.md" || sourceState.Kind != state.OutputKindSource || sourceState.SourcePath != "report.md" || sourceState.URL != sourceOutput.URL || sourceState.SHA256 != sourceOutput.SHA256 || sourceState.Size != sourceOutput.Size { + t.Fatalf("source state output = %#v", sourceState) + } + + generatedOutput := Output{ + SourcePath: "report.md", + DestinationPath: "report.html", + Kind: state.OutputKindGenerated, + Transform: transform.MarkdownToHTML, + URL: "https://reports.example.com/report.html", + SHA256: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + Size: 456, + } + generatedState := generatedOutput.StateOutputFile() + if generatedState.Path != "report.html" || generatedState.Kind != state.OutputKindGenerated || generatedState.SourcePath != "report.md" || generatedState.Transform != transform.MarkdownToHTML || generatedState.URL != generatedOutput.URL || generatedState.SHA256 != generatedOutput.SHA256 || generatedState.Size != generatedOutput.Size { + t.Fatalf("generated state output = %#v", generatedState) + } +} + +func TestOutputSliceProjections(t *testing.T) { + outputs := []Output{ + {SourcePath: "report.md", DestinationPath: "report.md", Kind: state.OutputKindSource, SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: 1}, + {SourcePath: "report.md", DestinationPath: "report.html", Kind: state.OutputKindGenerated, Transform: transform.MarkdownToHTML, URL: "https://reports.example.com/report.html", SHA256: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Size: 2}, + } + + stateOutputs := StateOutputFiles(outputs) + if len(stateOutputs) != 2 || stateOutputs[1].Path != "report.html" || stateOutputs[1].Transform != transform.MarkdownToHTML || stateOutputs[1].URL != outputs[1].URL { + t.Fatalf("state outputs = %#v", stateOutputs) + } + + paths := ManagedOutputPaths(outputs) + if len(paths) != 2 || paths[0] != "report.md" || paths[1] != "report.html" { + t.Fatalf("managed paths = %#v", paths) + } +} + func TestPlanOutputsRejectsCollision(t *testing.T) { sourceBackend := fake.New() sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{