diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e386291..6efc3df 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -73,6 +73,39 @@ jobs: hugo --gc --minify --panicOnWarning --printPathWarnings --baseURL "${DOCS_URL}/${{ steps.version.outputs.version }}/" + # ⛔ THE TWO DOCUMENTATION GUARDS. Both existed before the move from + # MkDocs to Hugo (#28), which rewrote this file and dropped them: they + # stayed in tools/ and nothing called them, so for two releases the pages + # could have stated any conformance total and any example output without + # a build disagreeing. tools/wired is the test that now fails when a tool + # under tools/ is named by no workflow, because a guard that is not wired + # is indistinguishable from one that passes -- both are silent. + # + # They run in `build`, which also covers pull requests, so a wrong figure + # blocks the change rather than being found after publication. + - uses: actions/setup-go@v6 + with: + go-version: '1.27.2' + + - name: Every tool under tools/ is called by a workflow + run: go test ./... + + - name: The stated conformance totals match the gate + run: go run ./tools/conformancefigure -root content/en + + # The examples carry their answers in `# =>` comments; this runs them. + # rbgo is built from the ruby repository's main so they are judged against + # the interpreter these pages describe. An interpreter that cannot be + # produced exits 2 -- a REFUSAL, not a pass. + - name: The Ruby examples print what the pages say they print + run: | + go run ./tools/docexample -selftest + # GOBIN names the destination: `go install` otherwise writes to + # $(go env GOPATH)/bin, and an install that lands elsewhere looks + # exactly like one that worked. + GOBIN=/tmp go install github.com/go-embedded-ruby/ruby/cmd/rbgo@main + go run ./tools/docexample -rbgo /tmp/rbgo -root content/en + - name: Lay out the site for the link check run: | mkdir -p _check/docs diff --git a/tools/conformancefigure/main.go b/tools/conformancefigure/main.go index 981a813..3cee10b 100644 --- a/tools/conformancefigure/main.go +++ b/tools/conformancefigure/main.go @@ -141,7 +141,10 @@ func allMatches(line string) []string { func main() { var ( - root = flag.String("root", "docs", "the directory of Markdown to check") + // content/en since the move to Hugo (#28); it was docs/ under MkDocs, + // and a default naming a directory that no longer exists meant every + // hand-run of this tool refused with "lstat docs: no such file". + root = flag.String("root", "content/en", "the directory of Markdown to check") baseline = flag.String("baseline", "", "read the authoritative BASELINE from this path instead of fetching it") timeout = flag.Duration("timeout", 30*time.Second, "how long to wait for the fetch") ) diff --git a/tools/wired/wired.go b/tools/wired/wired.go new file mode 100644 index 0000000..b2738b1 --- /dev/null +++ b/tools/wired/wired.go @@ -0,0 +1,98 @@ +// Package wired answers one question about this repository: is every tool +// under tools/ actually invoked by a workflow? +// +// Why it exists: the move from MkDocs to Hugo (#28) rewrote .github/workflows +// and dropped BOTH documentation guards. tools/conformancefigure, which checks +// that the conformance totals the site states agree with the gate, and +// tools/docexample, which runs every Ruby example and compares it with the +// answer written beside it, were still in the tree and called by nothing. The +// pages could have said anything for two releases and no build would have +// disagreed. +// +// Nothing caught it because nothing asserted it. A guard that is not wired is +// indistinguishable from a guard that passes: both are silent. +package wired + +import ( + "io/fs" + "os" + "path/filepath" + "strings" +) + +// Commands lists the tool directories under root. A directory counts when it +// holds Go source; wired itself is named explicitly rather than matched by +// that rule, because it is a library this test uses and not a tool a workflow +// should call. +func Commands(root string) ([]string, error) { + entries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + var out []string + for _, e := range entries { + if !e.IsDir() || e.Name() == "wired" { + continue + } + has, err := hasGo(filepath.Join(root, e.Name())) + if err != nil { + return nil, err + } + if has { + out = append(out, e.Name()) + } + } + return out, nil +} + +func hasGo(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") { + return true, nil + } + } + return false, nil +} + +// WorkflowText concatenates every workflow file, which is what a tool's name +// is looked for in. +func WorkflowText(dir string) (string, error) { + var b strings.Builder + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if ext := filepath.Ext(p); ext != ".yml" && ext != ".yaml" { + return nil + } + by, err := os.ReadFile(p) + if err != nil { + return err + } + b.Write(by) + b.WriteString("\n") + return nil + }) + if err != nil { + return "", err + } + return b.String(), nil +} + +// Unwired returns the commands no workflow mentions. +func Unwired(commands []string, workflows string) []string { + var out []string + for _, c := range commands { + if !strings.Contains(workflows, c) { + out = append(out, c) + } + } + return out +} diff --git a/tools/wired/wired_test.go b/tools/wired/wired_test.go new file mode 100644 index 0000000..9688343 --- /dev/null +++ b/tools/wired/wired_test.go @@ -0,0 +1,85 @@ +package wired_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-embedded-ruby/docs/tools/wired" +) + +// TestEveryToolIsWiredIntoAWorkflow is the gate. +// +// It reads the tool directories and the workflow files, and fails when a tool +// is named by none of them. That is deliberately a CRUDE test -- it looks for +// the directory's name in the YAML, not for a correctly formed step -- because +// the failure it exists for is not a subtle one: a tool nobody calls. A crude +// test that would have failed #28 beats a precise one nobody wrote. +func TestEveryToolIsWiredIntoAWorkflow(t *testing.T) { + root := repoRoot(t) + + cmds, err := wired.Commands(filepath.Join(root, "tools")) + if err != nil { + t.Fatalf("reading tools/: %v", err) + } + if len(cmds) == 0 { + t.Fatal("found no tools under tools/ -- this test would then pass by having " + + "nothing to check, which is how the guards went missing in the first place") + } + + wf, err := wired.WorkflowText(filepath.Join(root, ".github", "workflows")) + if err != nil { + t.Fatalf("reading .github/workflows: %v", err) + } + if strings.TrimSpace(wf) == "" { + t.Fatal("read no workflow text -- every tool would then look unwired") + } + + bad := wired.Unwired(cmds, wf) + if len(bad) > 0 { + t.Errorf("no workflow mentions %v.\n"+ + "A tool nothing calls is indistinguishable from a tool that passes: both are "+ + "silent. Either wire it into .github/workflows or delete it; leaving it is the "+ + "state that let the MkDocs-to-Hugo move (#28) drop both documentation guards "+ + "without a red build.", bad) + return + } + t.Logf("%d tool(s) under tools/, all named by a workflow: %v", len(cmds), cmds) +} + +// TestUnwiredNamesOnlyWhatIsMissing checks the rule itself against literals, so +// the gate above cannot pass because the comparison is broken. +func TestUnwiredNamesOnlyWhatIsMissing(t *testing.T) { + const yaml = " - run: go run ./tools/docexample -root content/en\n" + got := wired.Unwired([]string{"docexample", "conformancefigure"}, yaml) + if len(got) != 1 || got[0] != "conformancefigure" { + t.Errorf("Unwired = %v, want [conformancefigure]", got) + } + if n := len(wired.Unwired([]string{"docexample"}, yaml)); n != 0 { + t.Errorf("a wired tool was reported as unwired (%d)", n) + } + if n := len(wired.Unwired([]string{"docexample", "conformancefigure"}, "")); n != 2 { + t.Errorf("with no workflow text, %d of 2 tools looked wired", 2-n) + } +} + +// repoRoot walks up to the directory holding go.mod, so the test runs from +// wherever `go test ./...` places it. +func repoRoot(t *testing.T) string { + t.Helper() + d, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil { + return d + } + parent := filepath.Dir(d) + if parent == d { + t.Fatal("no go.mod above the test's directory") + } + d = parent + } +}