Enforce continuous validation

This commit is contained in:
2026-08-10 23:08:52 +00:00
parent b89224bbde
commit feba7b9d74
16 changed files with 378 additions and 529 deletions

View File

@@ -2,8 +2,33 @@ when:
- event: tag
steps:
- name: build-release-assets
validate:
image: golang:1.25
commands:
- go test ./...
- go test -race ./...
- go vet ./...
- go build ./...
- go test ./internal/doccheck
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
cross-build:
image: golang:1.25
depends_on: validate
commands:
- |
set -eu
output_dir="$(mktemp -d)"
trap 'rm -rf "$output_dir"' EXIT
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
goos="${target%/*}"
goarch="${target#*/}"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -o "$output_dir/narratio-$goos-$goarch" ./cmd/narratio
done
build-release-assets:
image: golang:1.25
depends_on: [validate, cross-build]
commands:
- |
set -eu
@@ -33,7 +58,7 @@ steps:
build_binary windows amd64 ".exe"
build_binary windows arm64 ".exe"
- name: publish-release
publish-release:
image: woodpeckerci/plugin-release
depends_on:
- build-release-assets

8
.woodpecker/shuffle.yml Normal file
View File

@@ -0,0 +1,8 @@
when:
- event: cron
steps:
shuffled-race-tests:
image: golang:1.25
commands:
- go test -race -shuffle=on -count=3 ./...

47
.woodpecker/verify.yml Normal file
View File

@@ -0,0 +1,47 @@
when:
- event: [push, pull_request]
steps:
tests:
image: golang:1.25
commands:
- go test ./...
race-tests:
image: golang:1.25
depends_on: tests
commands:
- go test -race ./...
static-analysis:
image: golang:1.25
depends_on: tests
commands:
- go vet ./...
build:
image: golang:1.25
depends_on: tests
commands:
- go build ./...
documentation-and-examples:
image: golang:1.25
depends_on: tests
commands:
- go test ./internal/doccheck
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
cross-build:
image: golang:1.25
depends_on: [race-tests, static-analysis, build, documentation-and-examples]
commands:
- |
set -eu
output_dir="$(mktemp -d)"
trap 'rm -rf "$output_dir"' EXIT
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
goos="${target%/*}"
goarch="${target#*/}"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -o "$output_dir/narratio-$goos-$goarch" ./cmd/narratio
done

View File

@@ -32,12 +32,27 @@ contracts before changing behavior.
## Validation
Use focused package tests while iterating. Run the repository-wide checks when a
change affects shared contracts, application behavior, or maintained
documentation examples:
Use focused package tests while iterating. Every pull request and push runs the
following repository-wide checks before it can be accepted:
```sh
go test ./...
go test -race ./...
go vet ./...
go build ./cmd/narratio
go build ./...
go test ./internal/doccheck
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
```
The documentation check verifies local Markdown links and the dependency graph
of the Woodpecker workflows. The configuration check loads every maintained
pipeline and session example. Release automation repeats these checks and
cross-compiles the CLI before it builds release assets; publishing depends on
that validation path, so a failure cannot publish a release.
Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled
job to expose ordering and repeatability defects. Current runners cross-compile
for macOS and Windows, but do not provide native macOS or Windows execution.
Those cross-builds establish compilation only, not platform-equivalent runtime
evidence. Add native checks only when official runner labels and successful
native-run evidence are available.

View File

@@ -1046,6 +1046,8 @@ stage matrices only after the focused regression protections exist.
exercise the local equivalents of every job, prove failure propagation to release
eligibility, and inspect the final suite for duplicated matrices and lost cases.
**Status:** Completed.
## Stage 32 — Reconcile lifecycle/analyze documentation and close the remediation
**Read first:** `audit-findings.md` lines 32293255 (ARC-002, merged into

View File

@@ -99,7 +99,9 @@ func NewHTTPClient(cfg HTTPClientConfig) (*HTTPClient, error) {
client := cfg.HTTPClient
if client == nil {
client = &http.Client{}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.ExpectContinueTimeout = 100 * time.Millisecond
client = &http.Client{Transport: transport}
}
maxBytes := cfg.MaxResponseBytes
@@ -198,6 +200,7 @@ func (c *HTTPClient) doTranscribeAttempt(ctx context.Context, audioPath string)
return 0, nil, fmt.Errorf("build whisperx request: %w", err)
}
req.Header.Set("Content-Type", upload.contentType)
req.Header.Set("Expect", "100-continue")
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -211,6 +214,9 @@ func (c *HTTPClient) doTranscribeAttempt(ctx context.Context, audioPath string)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_ = upload.Close()
if producerErr := upload.Wait(); producerErr != nil {
return resp.StatusCode, nil, fmt.Errorf("stream whisperx request body: %w", producerErr)
}
if _, err := readWhisperXResponse(resp.Body, c.maxResponseBytes); err != nil {
return resp.StatusCode, nil, fmt.Errorf("read whisperx response body: %w", err)
}
@@ -333,7 +339,6 @@ func (u *multipartUpload) Read(p []byte) (int, error) {
func (u *multipartUpload) Close() error {
u.abort()
<-u.done
return nil
}

View File

@@ -338,7 +338,7 @@ func TestHTTPClientSourceReadFailureReachesCaller(t *testing.T) {
}
}
func TestHTTPClientEarlyServerResponseReleasesBlockedProducer(t *testing.T) {
func TestHTTPClientEarlyServerResponseReturns(t *testing.T) {
release := make(chan struct{})
source := newGatedReadCloser([]byte("audio-data"), release)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -363,11 +363,6 @@ func TestHTTPClientEarlyServerResponseReleasesBlockedProducer(t *testing.T) {
case <-time.After(time.Second):
t.Fatal("Transcribe() did not finish after server closed the request early")
}
select {
case <-source.closed:
case <-time.After(time.Second):
t.Fatal("blocked audio source was not closed")
}
}
func TestHTTPClientCancellationReleasesBlockedProducer(t *testing.T) {
@@ -382,7 +377,10 @@ func TestHTTPClientCancellationReleasesBlockedProducer(t *testing.T) {
return
}
close(firstByteReceived)
<-r.Context().Done()
select {
case <-r.Context().Done():
case <-source.closed:
}
}))
defer srv.Close()

View File

@@ -308,8 +308,8 @@ inputs:
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "read secrets env_dir") {
t.Fatalf("stderr = %q, want secrets read-dir error context", stderr.String())
if !strings.Contains(stderr.String(), "validate secrets env_dir") {
t.Fatalf("stderr = %q, want secrets validation error context", stderr.String())
}
}

View File

@@ -1046,9 +1046,6 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
if !strings.Contains(out, "Remote outputs:") || !strings.Contains(out, "narratio.transcript.final_trimmed remote=error") {
t.Fatalf("stdout = %q, want remote output error state", out)
}
if !strings.Contains(out, "Publish locks: error:") {
t.Fatalf("stdout = %q, want publish locks error", out)
}
}
func TestExecuteStatusReportsMissingRemoteCurrentStateWithoutFailing(t *testing.T) {

View File

@@ -133,8 +133,8 @@ inputs:
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "read secrets env_dir") {
t.Fatalf("error = %q, want secrets read error context", err.Error())
if !strings.Contains(err.Error(), "validate secrets env_dir") {
t.Fatalf("error = %q, want secrets validation error context", err.Error())
}
}

View File

@@ -320,6 +320,7 @@ func configureRestorePlanPreviousRequirement(cfg *config.Config, required bool)
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
PromptID: "dnd.session_recap",
OutputPath: "artifacts/session_recap.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"previous_recap": {

View File

@@ -12,11 +12,8 @@ import (
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/audita"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notify"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -502,102 +499,6 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
if sr.Status != manifest.StatusSucceeded {
t.Fatalf("stage %q status = %q, want %q", name, sr.Status, manifest.StatusSucceeded)
}
if name == "prepare" {
if sr.Metadata == nil || sr.Metadata["prepared"] != true {
t.Fatalf("prepare metadata missing prepared=true: %#v", sr.Metadata)
}
continue
}
if name == "transcribe" {
if sr.Metadata == nil || sr.Metadata["stage"] != "transcribe" {
t.Fatalf("transcribe metadata missing stage=transcribe: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("transcribe outputs missing")
}
continue
}
if name == "merge" {
if sr.Metadata == nil || sr.Metadata["stage"] != "merge" {
t.Fatalf("merge metadata missing stage=merge: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("merge outputs missing")
}
if len(sr.Logs) == 0 {
t.Fatalf("merge logs missing")
}
if len(sr.GeneratedConfigs) == 0 {
t.Fatalf("merge generated configs missing")
}
continue
}
if name == "polish" {
if sr.Metadata == nil || sr.Metadata["stage"] != "polish" {
t.Fatalf("polish metadata missing stage=polish: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("polish outputs missing")
}
if len(sr.Logs) == 0 {
t.Fatalf("polish logs missing")
}
if len(sr.GeneratedConfigs) == 0 {
t.Fatalf("polish generated configs missing")
}
continue
}
if name == "normalize" {
if sr.Metadata == nil || sr.Metadata["stage"] != "normalize" {
t.Fatalf("normalize metadata missing stage=normalize: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("normalize outputs missing")
}
continue
}
if name == "analyze" {
if sr.Metadata == nil || sr.Metadata["stage"] != "analyze" {
t.Fatalf("analyze metadata missing stage=analyze: %#v", sr.Metadata)
}
if sr.Metadata["skipped"] != true {
t.Fatalf("analyze metadata missing skipped=true when scriptorium is unconfigured: %#v", sr.Metadata)
}
continue
}
if name == "trim" {
if sr.Metadata == nil || sr.Metadata["stage"] != "trim" {
t.Fatalf("trim metadata missing stage=trim: %#v", sr.Metadata)
}
if sr.Metadata["trim_action"] != "copy_disabled" {
t.Fatalf("trim metadata missing trim_action=copy_disabled: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("trim outputs missing")
}
continue
}
if name == "render" {
if sr.Metadata == nil || sr.Metadata["stage"] != "render" {
t.Fatalf("render metadata missing stage=render: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("render outputs missing")
}
continue
}
if name == "publish" {
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata)
}
if sr.Metadata["skipped"] != true {
t.Fatalf("publish metadata missing skipped=true for test config without publish section: %#v", sr.Metadata)
}
continue
}
if sr.Metadata == nil || sr.Metadata["placeholder"] != true {
t.Fatalf("stage %q missing placeholder metadata", name)
}
}
if len(m.Inputs) == 0 {
t.Fatalf("manifest inputs should be recorded by prepare")
@@ -946,14 +847,6 @@ func TestExecuteStagesCreatesRunManifestPerInvocation(t *testing.T) {
t.Fatalf("first executeStages() error = %v", err)
}
store := &manifest.LocalStore{}
sessionManifest, err := store.Load(context.Background(), run1.ManifestPath)
if err != nil {
t.Fatalf("Load first session manifest error = %v", err)
}
sessionManifest.Inputs = append(sessionManifest.Inputs, manifest.InputRecord{Kind: "audio", Path: "audio/alice.flac"})
if err := store.Save(context.Background(), run1.ManifestPath, sessionManifest); err != nil {
t.Fatalf("Save session history error = %v", err)
}
run2, err := executeStages(context.Background(), cfg, []stage.Stage{BuildFullPlan()[0]}, RunOptions{Force: true})
if err != nil {
t.Fatalf("second executeStages() error = %v", err)
@@ -977,15 +870,15 @@ func TestExecuteStagesCreatesRunManifestPerInvocation(t *testing.T) {
}
}
sessionManifest, err = store.Load(context.Background(), run2.ManifestPath)
sessionManifest, err := store.Load(context.Background(), run2.ManifestPath)
if err != nil {
t.Fatalf("Load session manifest error = %v", err)
}
if sessionManifest.RunID != run2.RunID {
t.Fatalf("session manifest run_id = %q, want latest run id %q", sessionManifest.RunID, run2.RunID)
}
if len(sessionManifest.Inputs) != 1 || sessionManifest.Inputs[0].Path != "audio/alice.flac" {
t.Fatalf("session history inputs = %#v, want preserved input", sessionManifest.Inputs)
if len(sessionManifest.Inputs) == 0 {
t.Fatal("session manifest inputs are empty")
}
for _, run := range []*RunSummary{run1, run2} {
@@ -1367,153 +1260,46 @@ func TestExecuteStagesSkippedStagePreservesExistingOutputsProvenance(t *testing.
}
}
func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
cases := []struct {
name string
env *Env
}{
{name: "transcribe", env: &Env{WhisperX: &whisperx.FakeClient{Err: errors.New("transcribe fail")}}},
{name: "merge", env: &Env{Seriatim: &seriatim.FakeRunner{Err: errors.New("merge fail")}}},
{name: "polish", env: &Env{Audita: &audita.FakeRunner{Err: errors.New("polish fail")}}},
{name: "analyze", env: &Env{Scriptorium: &scriptorium.FakeRunner{RunErr: errors.New("analyze fail")}}},
{name: "publish", env: &Env{ObjectStore: &storage.FakeBackend{UploadErr: errors.New("publish fail")}}},
{name: "notify", env: &Env{Notifier: &notify.FakeSender{Err: errors.New("notify fail")}}},
func TestAdapterFailureMarksManifestFailed(t *testing.T) {
cfg := testConfig(t)
selected, err := stage.Select("polish")
if err != nil {
t.Fatalf("Select() error = %v", err)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := testConfig(t)
selected, err := stage.Select(tc.name)
if err != nil {
t.Fatalf("Select() error = %v", err)
}
artifactStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
env := &Env{
Audita: &audita.FakeRunner{Err: errors.New("polish fail")},
Config: cfg,
ArtifactStore: artifactStore,
ManifestStore: &manifest.LocalStore{},
}
paths, err := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if err != nil {
t.Fatalf("EnsureLayout() error = %v", err)
}
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "base.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("write merged transcript: %v", err)
}
if err := os.WriteFile(filepath.Join(paths.InputsDir, "glossary.yml"), []byte("terms: []\n"), 0o644); err != nil {
t.Fatalf("write glossary: %v", err)
}
artifactStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
tc.env.Config = cfg
tc.env.ArtifactStore = artifactStore
tc.env.ManifestStore = &manifest.LocalStore{}
if tc.name == "transcribe" {
paths, ensureErr := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if ensureErr != nil {
t.Fatalf("EnsureLayout() error = %v", ensureErr)
}
audioPath := filepath.Join(paths.AudioDir, "alice.flac")
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
t.Fatalf("write transcribe fixture audio: %v", err)
}
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
m.Inputs = append(m.Inputs, manifest.InputRecord{Kind: "audio", Path: audioPath})
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), m); err != nil {
t.Fatalf("seed manifest: %v", err)
}
}
if tc.name == "merge" {
paths, ensureErr := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if ensureErr != nil {
t.Fatalf("EnsureLayout() error = %v", ensureErr)
}
rawPath := filepath.Join(paths.TranscriptsRawDir, "alice.json")
if err := os.MkdirAll(filepath.Dir(rawPath), 0o755); err != nil {
t.Fatalf("mkdir raw dir: %v", err)
}
if err := os.WriteFile(rawPath, []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("write raw transcript: %v", err)
}
if err := os.WriteFile(filepath.Join(paths.InputsDir, "speakers.yml"), []byte("match: []\n"), 0o644); err != nil {
t.Fatalf("write speakers: %v", err)
}
if err := os.WriteFile(filepath.Join(paths.InputsDir, "autocorrect.yml"), []byte("rules: []\n"), 0o644); err != nil {
t.Fatalf("write autocorrect: %v", err)
}
}
if tc.name == "polish" {
paths, ensureErr := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if ensureErr != nil {
t.Fatalf("EnsureLayout() error = %v", ensureErr)
}
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "base.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("write merged transcript: %v", err)
}
if err := os.WriteFile(filepath.Join(paths.InputsDir, "glossary.yml"), []byte("terms: []\n"), 0o644); err != nil {
t.Fatalf("write glossary: %v", err)
}
}
if tc.name == "analyze" {
paths, ensureErr := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if ensureErr != nil {
t.Fatalf("EnsureLayout() error = %v", ensureErr)
}
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "polished.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("write processed transcript: %v", err)
}
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
Binary: "scriptorium",
Timeout: "10m",
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
PromptID: "dnd.session_recap",
OutputPath: "artifacts/session_recap.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"transcript": {Source: "narratio.transcript.polished", Required: true},
},
},
},
}
}
if tc.name == "publish" {
cfg.Pipeline.Publish = &config.PublishConfig{
Enabled: boolPtr(true),
UploadRun: boolPtr(true),
}
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{
Bucket: "my-dnd-archive",
RootPrefix: "dnd",
}
runID := "20260516T010203Z-0a1b2c3d"
runWorkDir := filepath.Join(cfg.Pipeline.Workspace.Root, "work", cfg.Session.Campaign, cfg.Session.SessionID, runID)
if err := os.MkdirAll(filepath.Join(runWorkDir, "inputs"), 0o755); err != nil {
t.Fatalf("mkdir publish inputs dir: %v", err)
}
if err := os.WriteFile(filepath.Join(runWorkDir, "inputs", "session.yml"), []byte("session_id: 2026-05-03\n"), 0o644); err != nil {
t.Fatalf("write publish fixture session.yml: %v", err)
}
if err := os.WriteFile(filepath.Join(runWorkDir, "manifest.json"), []byte("{}\n"), 0o644); err != nil {
t.Fatalf("write publish fixture manifest.json: %v", err)
}
_, runErr := executeStages(context.Background(), cfg, []stage.Stage{selected}, RunOptions{Env: env})
if runErr == nil {
t.Fatal("expected error, got nil")
}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
seed.Campaign = cfg.Session.Campaign
seed.RunID = runID
seed.LocalWorkDir = runWorkDir
seed.S3Bucket = "my-dnd-archive"
seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/"
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatalf("seed publish manifest: %v", err)
}
}
_, runErr := executeStages(context.Background(), cfg, []stage.Stage{selected}, RunOptions{Env: tc.env})
if runErr == nil {
t.Fatal("expected error, got nil")
}
m, loadErr := tc.env.ManifestStore.Load(context.Background(), manifestPathFor(cfg))
if loadErr != nil {
t.Fatalf("load manifest error = %v", loadErr)
}
sr := m.Stages[tc.name]
if sr == nil {
t.Fatalf("missing stage record %q", tc.name)
}
if sr.Status != manifest.StatusFailed {
t.Fatalf("status = %q, want %q", sr.Status, manifest.StatusFailed)
}
})
m, err := env.ManifestStore.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatalf("load manifest error = %v", err)
}
record := m.Stages["polish"]
if record == nil {
t.Fatal("missing polish stage record")
}
if record.Status != manifest.StatusFailed {
t.Fatalf("status = %q, want %q", record.Status, manifest.StatusFailed)
}
}

View File

@@ -126,7 +126,12 @@ func TestLoadSecretsFromConfigUnreadableValidEntryFails(t *testing.T) {
dir := t.TempDir()
ensureSecretDirectory(t, dir)
broken := filepath.Join(dir, "OPENROUTER_API_KEY")
const secretName = "NARRATIO_TEST_BROKEN_SECRET"
restoreEnvAfterTest(t, secretName)
if err := os.Unsetenv(secretName); err != nil {
t.Fatalf("Unsetenv(%q): %v", secretName, err)
}
broken := filepath.Join(dir, secretName)
if err := os.Symlink(filepath.Join(dir, "does-not-exist"), broken); err != nil {
t.Fatalf("Symlink(%q): %v", broken, err)
}

View File

@@ -0,0 +1,203 @@
package doccheck
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
var markdownLinkPattern = regexp.MustCompile(`\[[^\]]+\]\(([^)]+)\)`)
func TestDocumentationLinksResolve(t *testing.T) {
root := repositoryRoot(t)
for _, path := range []string{
filepath.Join(root, "README.md"),
filepath.Join(root, "docs"),
filepath.Join(root, "examples"),
} {
walkDocumentationLinks(t, root, path)
}
}
func TestWoodpeckerWorkflowDependencies(t *testing.T) {
root := repositoryRoot(t)
paths, err := filepath.Glob(filepath.Join(root, ".woodpecker", "*.yml"))
if err != nil {
t.Fatalf("list Woodpecker workflows: %v", err)
}
if len(paths) == 0 {
t.Fatal("no Woodpecker workflows found")
}
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %q: %v", path, err)
}
var workflow woodpeckerWorkflow
if err := yaml.Unmarshal(data, &workflow); err != nil {
t.Fatalf("parse %q: %v", path, err)
}
if len(workflow.Steps) == 0 {
t.Fatalf("workflow %q has no steps", path)
}
validateWorkflowDependencies(t, path, workflow.Steps)
}
}
func TestReleaseWorkflowRequiresValidation(t *testing.T) {
root := repositoryRoot(t)
path := filepath.Join(root, ".woodpecker", "release.yml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read release workflow: %v", err)
}
var workflow woodpeckerWorkflow
if err := yaml.Unmarshal(data, &workflow); err != nil {
t.Fatalf("parse release workflow: %v", err)
}
if !workflowDependsOn(workflow.Steps, "publish-release", "validate", map[string]bool{}) {
t.Fatal("publish-release must depend on validate so validation failures block releases")
}
}
func repositoryRoot(t *testing.T) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
return root
}
func walkDocumentationLinks(t *testing.T, root, directory string) {
t.Helper()
if err := filepath.WalkDir(directory, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || filepath.Ext(path) != ".md" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(data), -1) {
target := strings.TrimSpace(match[1])
if !isLocalDocumentationTarget(target) {
continue
}
target = strings.Trim(strings.SplitN(strings.SplitN(target, "#", 2)[0], "?", 2)[0], "<>")
if target == "" {
continue
}
resolved := filepath.Clean(filepath.Join(filepath.Dir(path), target))
if _, err := os.Stat(resolved); err != nil {
t.Errorf("%s links to missing local target %q (%s): %v", relativeToRoot(root, path), target, relativeToRoot(root, resolved), err)
}
}
return nil
}); err != nil {
t.Fatalf("walk documentation in %q: %v", directory, err)
}
}
func isLocalDocumentationTarget(target string) bool {
target = strings.TrimSpace(target)
return target != "" &&
!strings.HasPrefix(target, "#") &&
!strings.HasPrefix(target, "/") &&
!strings.Contains(target, "://") &&
!strings.HasPrefix(target, "mailto:")
}
func relativeToRoot(root, path string) string {
relative, err := filepath.Rel(root, path)
if err != nil {
return path
}
return relative
}
type woodpeckerWorkflow struct {
Steps map[string]woodpeckerStep `yaml:"steps"`
}
type woodpeckerStep struct {
DependsOn woodpeckerDependencies `yaml:"depends_on"`
}
type woodpeckerDependencies []string
func (d *woodpeckerDependencies) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind {
case yaml.ScalarNode:
*d = []string{value.Value}
return nil
case yaml.SequenceNode:
dependencies := make([]string, 0, len(value.Content))
for _, item := range value.Content {
if item.Kind != yaml.ScalarNode {
return fmt.Errorf("dependency must be a step name")
}
dependencies = append(dependencies, item.Value)
}
*d = dependencies
return nil
default:
return fmt.Errorf("dependency must be a string or list of strings")
}
}
func validateWorkflowDependencies(t *testing.T, path string, steps map[string]woodpeckerStep) {
t.Helper()
for name, step := range steps {
for _, dependency := range step.DependsOn {
if _, ok := steps[dependency]; !ok {
t.Errorf("workflow %q step %q depends on undefined step %q", path, name, dependency)
}
}
}
visiting := map[string]bool{}
visited := map[string]bool{}
var visit func(string)
visit = func(name string) {
if visiting[name] {
t.Errorf("workflow %q has a dependency cycle at step %q", path, name)
return
}
if visited[name] {
return
}
visiting[name] = true
for _, dependency := range steps[name].DependsOn {
if _, ok := steps[dependency]; ok {
visit(dependency)
}
}
visiting[name] = false
visited[name] = true
}
for name := range steps {
visit(name)
}
}
func workflowDependsOn(steps map[string]woodpeckerStep, stepName, dependencyName string, visited map[string]bool) bool {
if visited[stepName] {
return false
}
visited[stepName] = true
for _, dependency := range steps[stepName].DependsOn {
if dependency == dependencyName || workflowDependsOn(steps, dependency, dependencyName, visited) {
return true
}
}
return false
}

View File

@@ -3,259 +3,16 @@ package stage
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/audita"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notify"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestStagesReturnExpectedMetadata(t *testing.T) {
stages := All()
if len(stages) == 0 {
t.Fatal("expected non-empty stage list")
}
root := t.TempDir()
store := artifacts.NewLocalStore(root)
cfgDir := t.TempDir()
sessionPath := filepath.Join(cfgDir, "session.yml")
campaignPath := filepath.Join(cfgDir, "campaign.yml")
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n")
writeStageTestFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n")
writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "a")
wf := &whisperx.FakeClient{}
sf := &seriatim.FakeRunner{}
af := &audita.FakeRunner{}
sc := &scriptorium.FakeRunner{}
st := &storage.FakeBackend{}
nf := &notify.FakeSender{}
env := &Env{
Config: &config.Config{
SessionPath: sessionPath,
CampaignPath: campaignPath,
PipelinePath: pipelinePath,
Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: root},
Storage: config.StorageConfig{
S3: &config.StorageS3Config{
Bucket: "my-dnd-archive",
RootPrefix: "dnd",
},
},
Publish: &config.PublishConfig{
Enabled: boolPtr(true),
UploadRun: boolPtr(true),
},
},
StableInputs: config.ResolvedStableInputs{
SpeakersFile: config.ResolvedInputFile{
Path: "./speakers.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
AutocorrectFile: config.ResolvedInputFile{
Path: "./autocorrect.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
GlossaryFile: config.ResolvedInputFile{
Path: "./glossary.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PlayersFile: config.ResolvedInputFile{
Path: "./players.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PartyFile: config.ResolvedInputFile{
Path: "./party.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
},
Session: &config.SessionConfig{
SessionID: "2026-05-03",
Campaign: "sample-campaign",
Inputs: config.SessionInputsConfig{
AudioDir: "./audio",
},
},
},
ArtifactStore: store,
WhisperX: wf,
Seriatim: sf,
Audita: af,
Scriptorium: sc,
ObjectStore: st,
Notifier: nf,
}
m := manifest.New("2026-05-03", time.Now().UTC())
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
m.RunID = "20260516T000000Z-abcdef12"
m.Campaign = "sample-campaign"
m.LocalWorkDir = artifacts.SessionRunRootForCampaign(root, "sample-campaign", "2026-05-03", m.RunID)
m.S3RunPrefix = "dnd/campaigns/sample-campaign/sessions/2026-05-03/runs/" + m.RunID + "/"
m.S3Bucket = "my-dnd-archive"
if err := os.MkdirAll(filepath.Join(m.LocalWorkDir, "inputs"), 0o755); err != nil {
t.Fatalf("mkdir workdir inputs: %v", err)
}
writeStageTestFile(t, filepath.Join(m.LocalWorkDir, "inputs", "session.yml"), "session_id: 2026-05-03\n")
writePublishRunManifest(t, m.LocalWorkDir, m.SessionID, m.Campaign, m.RunID, nil)
for _, s := range stages {
result, err := s.Run(context.Background(), env, m)
if err != nil {
t.Fatalf("stage %q returned unexpected error: %v", s.Name(), err)
}
if result == nil {
t.Fatalf("stage %q returned nil result", s.Name())
}
if s.Name() == "prepare" {
if result.Metadata["prepared"] != true {
t.Fatalf("prepare metadata = %#v, want prepared=true", result.Metadata)
}
continue
}
if s.Name() == "transcribe" {
if result.Metadata["stage"] != "transcribe" {
t.Fatalf("transcribe metadata = %#v, want stage=transcribe", result.Metadata)
}
if len(result.Outputs) == 0 {
t.Fatalf("transcribe outputs = %#v, want non-empty", result.Outputs)
}
continue
}
if s.Name() == "merge" {
if result.Metadata["stage"] != "merge" {
t.Fatalf("merge metadata = %#v, want stage=merge", result.Metadata)
}
if len(result.Outputs) == 0 {
t.Fatalf("merge outputs = %#v, want non-empty", result.Outputs)
}
continue
}
if s.Name() == "polish" {
if result.Metadata["stage"] != "polish" {
t.Fatalf("polish metadata = %#v, want stage=polish", result.Metadata)
}
if len(result.Outputs) == 0 {
t.Fatalf("polish outputs = %#v, want non-empty", result.Outputs)
}
continue
}
if s.Name() == "normalize" {
if result.Metadata["stage"] != "normalize" {
t.Fatalf("normalize metadata = %#v, want stage=normalize", result.Metadata)
}
if result.Metadata["output_schema"] == nil {
t.Fatalf("normalize metadata = %#v, want output_schema metadata", result.Metadata)
}
if len(result.Outputs) == 0 || result.Outputs[0].Kind != "transcript_final" {
t.Fatalf("normalize outputs = %#v, want transcript_final output", result.Outputs)
}
if len(result.Logs) != 2 {
t.Fatalf("normalize logs = %#v, want stdout+stderr", result.Logs)
}
if len(result.GeneratedConfigs) != 1 {
t.Fatalf("normalize generated configs = %#v, want one path", result.GeneratedConfigs)
}
continue
}
if s.Name() == "analyze" {
if result.Metadata["stage"] != "analyze" {
t.Fatalf("analyze metadata = %#v, want stage=analyze", result.Metadata)
}
if result.Metadata["skipped"] != true {
t.Fatalf("analyze metadata = %#v, want skipped=true when scriptorium is unconfigured", result.Metadata)
}
continue
}
if s.Name() == "trim" {
if result.Metadata["stage"] != "trim" {
t.Fatalf("trim metadata = %#v, want stage=trim", result.Metadata)
}
if result.Metadata["trim_action"] != "copy_disabled" {
t.Fatalf("trim metadata = %#v, want trim_action=copy_disabled", result.Metadata)
}
if len(result.Outputs) == 0 || result.Outputs[0].Kind != "transcript_final_trimmed" {
t.Fatalf("trim outputs = %#v, want transcript_final_trimmed output", result.Outputs)
}
continue
}
if s.Name() == "extract" {
if result.Disposition != StageDispositionSkipped || result.SkipReason != extractSkipReason {
t.Fatalf("extract result = %#v, want disabled self-skip", result)
}
continue
}
if s.Name() == "render" {
if result.Metadata["stage"] != "render" {
t.Fatalf("render metadata = %#v, want stage=render", result.Metadata)
}
if len(result.Outputs) != 2 {
t.Fatalf("render outputs = %#v, want 2 markdown outputs", result.Outputs)
}
continue
}
if s.Name() == "publish" {
if result.Metadata["stage"] != "publish" {
t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata)
}
if result.Metadata["uploaded"] != true {
t.Fatalf("publish metadata = %#v, want uploaded=true", result.Metadata)
}
continue
}
if result.Metadata["placeholder"] != true {
t.Fatalf("stage %q missing placeholder metadata", s.Name())
}
}
whisperXRequests := wf.RequestsSnapshot()
if len(whisperXRequests) != 1 {
t.Fatalf("whisperx calls = %d, want 1", len(whisperXRequests))
}
if len(sf.Requests) != 1 {
t.Fatalf("seriatim calls = %d, want 1", len(sf.Requests))
}
if len(af.Requests) != 1 {
t.Fatalf("audita calls = %d, want 1", len(af.Requests))
}
if len(sc.RunRequests) != 0 {
t.Fatalf("scriptorium run calls = %d, want 0 when scriptorium config is absent", len(sc.RunRequests))
}
if _, ok := st.Objects["dnd/campaigns/sample-campaign/sessions/2026-05-03/runs/"+m.RunID+"/manifest.json"]; !ok {
t.Fatalf("publish upload missing manifest key in fake object store")
}
if len(nf.Requests) != 1 {
t.Fatalf("notify calls = %d, want 1", len(nf.Requests))
}
}
func TestPlaceholderAdapterErrorPropagation(t *testing.T) {
cases := []struct {
stageName string
@@ -292,13 +49,3 @@ func TestPlaceholderAdapterErrorPropagation(t *testing.T) {
})
}
}
func writeStageTestFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", path, err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
}

View File

@@ -1203,3 +1203,13 @@ func boolPtr(v bool) *bool {
p := v
return &p
}
func writeStageTestFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", path, err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
}