Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion tools/conformancefigure/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
Expand Down
98 changes: 98 additions & 0 deletions tools/wired/wired.go
Original file line number Diff line number Diff line change
@@ -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
}
85 changes: 85 additions & 0 deletions tools/wired/wired_test.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading