Fiesta's module, gate and CI steps
Task 1 of the Fiesta part-1 plan: the module amadan.net/rastrillo/fiesta and the gate that every later task runs before it commits. The gate is rastrillo's shape so that a runner can be switched on later with no change here: `make ci` is the one definition, .amadan/ci.d/ steps are bare `exec make <target>` files at mode 100755, and 50-suite@4 names the suite's four roles. Two tests are new. TestFiestaNeverImportsRastrillo and TestThePublicPackagesLinkNoBrowser are the "No Rastrillo dependency" constraint as code: Rastrillo adopts Fiesta, so a path back would be a cycle, and an app that embeds the CSS must not take on a Chrome driver. Each was shown red with a one-line mutation in a scratch copy (a require line and an import of rastrillo; an import of chromedp in the root package) and green without it. ci_test.go is rastrillo's amadan_ci_test.go; its exec-bit test was shown red first, on 10-gofmt staged at mode 100644. Ported from rastrillo at e14b745, checked against origin/main: hack/suite.sh, staticcheck.conf and .amadan/ci are byte-identical; hack/gotest.sh differs only by RASTRILLO_CHROME -> FIESTA_CHROME (its comments still cite rastrillo's ui package as the example); the Makefile keeps GOTOOLCHAIN, CGO_ENABLED, GOTMPDIR, the gofmt, suite, suite-roles and go-roles blocks, the STATICCHECK, GOVULNCHECK and GITLEAKS pins and the STATICCHECK_CACHE rule, and drops every Rastrillo-only target (money, the examples, chromedp-graph, gorm-free, generate-check, scaffold-smoke, race, mirror). GOFLAGS=-mod=mod is not carried: it existed for rastrillo's scratch modules, which Fiesta has none of. In ci_test.go the two messages that cited AGENTS.md now cite the Makefile's ci comment, which is where the sentence lives here. hack/test-weights.txt is empty of weights and keeps the format comment. SUITE ends in $(BROWSER_ROLES), empty now, so a later task adds a browser package by setting BROWSER_PKGS alone.
Summary
Adds go.mod, doc.go, the Makefile with ci/gofmt/no-rastrillo/suite and the four roles root, staticcheck, govulncheck and gitleaks, hack/suite.sh and hack/gotest.sh from rastrillo, .amadan/ci with three ci.d steps, staticcheck.conf, ci_test.go and norastrillo_test.go.
Agent notes
make ci passes locally with 4 suite roles; TestSlicesPartitionTheirPackages skips until a package is sliced (Task 8). No fleet lane: CI is local for now (controller decision 2).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
13 files changed,
+880
−0
.amadan/ci+6 −0.amadan/ci.d/10-gofmt+2 −0.amadan/ci.d/20-no-rastrillo+2 −0.amadan/ci.d/50-suite@4+2 −0Makefile+180 −0ci_test.go+336 −0doc.go+6 −0go.mod+3 −0hack/gotest.sh+187 −0hack/suite.sh+46 −0hack/test-weights.txt+8 −0norastrillo_test.go+92 −0staticcheck.conf+10 −0
diff --git a/.amadan/ci b/.amadan/ci| new file mode 100755 |
| index 0000000..8c547ee |
| --- /dev/null |
| +++ b/.amadan/ci |
| @@ -0,0 +1,6 @@ |
| +#!/bin/sh |
| +# amadan CI entry (single-script fallback for runners without step |
| +# support). The steps in ci.d/ are the same targets, reported one by |
| +# one. Must stay executable: a non-executable script resolves "skipped". |
| +set -e |
| +exec make ci |
diff --git a/.amadan/ci.d/10-gofmt b/.amadan/ci.d/10-gofmt| new file mode 100755 |
| index 0000000..e234af5 |
| --- /dev/null |
| +++ b/.amadan/ci.d/10-gofmt |
| @@ -0,0 +1,2 @@ |
| +#!/bin/sh |
| +exec make gofmt |
diff --git a/.amadan/ci.d/20-no-rastrillo b/.amadan/ci.d/20-no-rastrillo| new file mode 100755 |
| index 0000000..58315ed |
| --- /dev/null |
| +++ b/.amadan/ci.d/20-no-rastrillo |
| @@ -0,0 +1,2 @@ |
| +#!/bin/sh |
| +exec make no-rastrillo |
diff --git a/.amadan/ci.d/50-suite@4 b/.amadan/ci.d/50-suite@4| new file mode 100755 |
| index 0000000..da26e40 |
| --- /dev/null |
| +++ b/.amadan/ci.d/50-suite@4 |
| @@ -0,0 +1,2 @@ |
| +#!/bin/sh |
| +exec make suite |
diff --git a/Makefile b/Makefile| new file mode 100644 |
| index 0000000..b93944c |
| --- /dev/null |
| +++ b/Makefile |
| @@ -0,0 +1,180 @@ |
| +.PHONY: ci suite suite-roles gofmt no-rastrillo root staticcheck govulncheck gitleaks browser |
| + |
| +# CGO_ENABLED=0 across the tree is an acceptance criterion, not a |
| +# preference: Fiesta is CSS, scripts and the Go that embeds and checks |
| +# them, and nothing here may quietly pull in a cgo dependency because |
| +# a C toolchain happened to be present. |
| +export CGO_ENABLED = 0 |
| + |
| +# The gate runs on one known Go, not whichever release the machine has. |
| +# govulncheck reports the standard library's vulnerabilities for the |
| +# toolchain doing the scan, so a runner one patch release behind would |
| +# fail the gate for code nobody changed, and a laptop and a runner on |
| +# different patches would disagree about what passing means. go fetches |
| +# this release through the module proxy on first use. |
| +# |
| +# A plain =, not ?=: Go's own container images set GOTOOLCHAIN=local, and |
| +# ?= would quietly keep whatever Go they ship. Override on the command |
| +# line if you must: make ci GOTOOLCHAIN=local. Raise it with each Go |
| +# security release; govulncheck naming a stdlib package "Found in" this |
| +# version is the signal. |
| +# |
| +# Held on 1.26 deliberately, as rastrillo is. Go 1.27 writes export |
| +# data that x/tools before v0.50.0 cannot read, and v0.50.0 needs go |
| +# 1.26 in go.mod: moving to 1.27 raises the Go floor for every app that |
| +# imports Fiesta, so it is its own change. |
| +export GOTOOLCHAIN = go1.26.10 |
| + |
| +BIN := $(CURDIR)/.build |
| + |
| +# Keep compiler/linker scratch on the checkout filesystem when shared /tmp is full. |
| +export GOTMPDIR ?= $(BIN)/tmp |
| + |
| +# ci is the one gate: what a runner executes and what you run before |
| +# pushing are the same definition. .amadan/ci.d/ reports these one by |
| +# one; it never keeps its own copy of a command. |
| +# |
| +# The cheap checks go first and alone, so a stray gofmt still costs |
| +# seconds. Everything else is the suite. |
| +ci: gofmt no-rastrillo suite |
| + |
| +# The suite's roles. Under amadan, .amadan/ci.d/50-suite@N runs N |
| +# shards at once and each runs one role, so the run takes as long as |
| +# the slowest role rather than the sum of them all; by hand, or as a |
| +# step with no @N, the roles run one after another. N must equal the |
| +# number of roles here: hack/suite.sh refuses any other count rather |
| +# than run some roles twice and others nowhere, and ci_test.go checks |
| +# the two agree before a runner ever sees them. |
| +# |
| +# The slowest role is how long the suite takes, so the slow packages |
| +# are roles of their own. The browser packages are a role each. A |
| +# package written pkg@n is n roles, each a slice of its tests, dealt |
| +# by hack/gotest.sh. In a role's name a / in the path is a . so |
| +# check/drives@2 would become browser.check.drives~1of2 and ~2of2. |
| +# BROWSER_PKGS is empty until the browser packages exist; the tasks |
| +# that add them append here and rename 50-suite@N to match. |
| +BROWSER_PKGS := |
| +go-roles = $(foreach p,$(2),$(if $(findstring @,$(p)),$(call go-slices,$(1),$(subst /,.,$(word 1,$(subst @, ,$(p)))),$(word 2,$(subst @, ,$(p)))),$(1).$(subst /,.,$(p)))) |
| +go-slices = $(foreach k,$(shell seq 1 $(3)),$(1).$(2)~$(k)of$(3)) |
| +BROWSER_ROLES := $(call go-roles,browser,$(BROWSER_PKGS)) |
| +SUITE := root staticcheck govulncheck gitleaks $(BROWSER_ROLES) |
| + |
| +suite: |
| + @MAKE='$(MAKE)' ./hack/suite.sh $(SUITE) |
| + |
| +# What ci_test.go counts against the step's @N. |
| +suite-roles: |
| + @echo $(SUITE) |
| + |
| +# The repo's own Go files, not everything under the checkout: GOTMPDIR is |
| +# .build/tmp, and a go command killed mid-build (a Ctrl-C, or a runner |
| +# cancelling a job a newer push superseded) leaves its generated |
| +# _testmain.go and cgo files there. gofmt -l . walked into them and failed |
| +# the next run on files nobody wrote. Dot-directories are pruned, the same |
| +# rule ./... applies. It is a walk of the working tree rather than a git |
| +# listing, so an unstaged deletion is simply absent instead of a missing |
| +# path. NUL-delimited so a path with a space stays one argument, and a |
| +# gofmt error (a file that does not parse) fails the target instead of |
| +# vanishing into stderr. |
| +gofmt: |
| + @out=$$(find . -name '.?*' -type d -prune -o -name '*.go' -type f -print0 | xargs -0 gofmt -l) || exit 1; \ |
| + if [ -n "$$out" ]; then echo "gofmt needed on:"; echo "$$out"; exit 1; fi |
| + |
| +# Fiesta never imports Rastrillo, and the packages a Go program imports |
| +# from Fiesta never link a browser. Both are tests in norastrillo_test.go; |
| +# this runs them alone, first, so a broken promise is the first thing a |
| +# red run says. |
| +no-rastrillo: |
| + go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$$' -count=1 . |
| + |
| +# FIESTA_TEST_REQUIRE_NODE turns every Node skip into a failure here: on |
| +# a laptop without Node a skip is honest, but a gate that lost node |
| +# would otherwise go green having checked none of the JavaScript. |
| +# Override with FIESTA_TEST_REQUIRE_NODE= to run without it. |
| +FIESTA_TEST_REQUIRE_NODE ?= 1 |
| +# root builds, vets and tests every package. The browser drives are |
| +# behind the browser tag and run as roles of their own. |
| +root: |
| + go build ./... |
| + go vet ./... |
| + FIESTA_TEST_REQUIRE_NODE=$(FIESTA_TEST_REQUIRE_NODE) go test -count=1 ./... |
| + |
| +# test.<pkg>[~<k>of<n>]: one package of plain tests as a role of its own, |
| +# for a package too slow to sit inside root. |
| +test.%: | $(BIN)/tmp |
| + FIESTA_TEST_REQUIRE_NODE=$(FIESTA_TEST_REQUIRE_NODE) ./hack/gotest.sh test '$*' |
| + |
| +# staticcheck catches what vet does not: deprecated APIs, values that are |
| +# never read, and the &*x that looks like a copy and is not. |
| +# staticcheck.conf at the root says which checks. |
| +# |
| +# go run with a version, not a tool directive: Fiesta is a library, and |
| +# a tool's requirements in this go.mod would join every consuming app's |
| +# module graph. Bump it alongside the go directive - a staticcheck older |
| +# than the Go it reads does not know that release's deprecations, and |
| +# says nothing - and alongside a new minor GOTOOLCHAIN, whose export |
| +# data an older one cannot decode at all. |
| +# |
| +# -tags browser because no file is excluded by it: it is a strict superset |
| +# of the plain build, and it is the only way the browser drives get read. |
| +STATICCHECK := honnef.co/go/tools/cmd/staticcheck@v0.8.2 |
| +# staticcheck keeps its cache in the user cache directory, ~/.cache, |
| +# which a sandboxed CI runner mounts read-only: "failed to initialize |
| +# build cache ... read-only file system". Where the runner hands the job |
| +# an absolute GOCACHE, staticcheck's goes inside it: writable wherever |
| +# Go's is, and evicted with it. Anywhere else it stays where staticcheck |
| +# puts it. Not beside it: a writable GOCACHE says nothing about its |
| +# parent. |
| +ifneq ($(filter /%,$(firstword $(GOCACHE))),) |
| +export STATICCHECK_CACHE ?= $(GOCACHE)/staticcheck |
| +endif |
| + |
| +staticcheck: |
| + go run $(STATICCHECK) -tags browser ./... |
| + |
| +# govulncheck reports only vulnerabilities this code can reach, so a |
| +# finding is a real call path, not a dependency merely present in go.sum. |
| +# It reads the live vulnerability database, so it can go red with no |
| +# change here: that is the point of running it on every push. A module |
| +# finding is fixed by raising the requirement; a standard-library one by |
| +# raising GOTOOLCHAIN above. |
| +GOVULNCHECK := golang.org/x/vuln/cmd/govulncheck@v1.8.0 |
| +govulncheck: |
| + go run $(GOVULNCHECK) ./... |
| + |
| +# The whole git history, not only the tree: a key committed and deleted |
| +# a year ago is still in every clone, and this repository is public. |
| +# --redact keeps anything it does find out of the CI log. |
| +# |
| +# The history of HEAD, not of every ref: gitleaks' default is |
| +# `git log --all`, and a runner's clone keeps a ref for every branch it |
| +# has built, so one branch's leak turned every other branch red, |
| +# main included. A leak on a branch is that branch's own run's to find. |
| +# The rest of the options are gitleaks' own defaults, which --log-opts |
| +# replaces wholesale. |
| +GITLEAKS := github.com/zricethezav/gitleaks/v8@v8.30.1 |
| +gitleaks: |
| + go run $(GITLEAKS) git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' . |
| + |
| +# The browser drives, one package or slice per browser.<pkg>[~<k>of<n>] |
| +# target (a / in the path is a . in the name). `make browser` runs them |
| +# one after another; the suite runs each as its own shard. |
| +# FIESTA_BROWSER_OPTIONAL stays unset on purpose: a skip is not a pass, |
| +# so a machine that loses its browser fails loudly instead of reporting |
| +# green. Chromium profiles also need room when the shared /tmp tmpfs |
| +# fills, which hack/gotest.sh handles. |
| +browser: $(BROWSER_ROLES) |
| + |
| +# The browser packages that need full Chromium rather than a headless |
| +# shell, where both are installed (hack/gotest.sh). check's drives |
| +# include the busy button, which needs the back/forward cache the |
| +# shell never restores. |
| +export FULL_CHROMIUM := check |
| + |
| +browser.%: | $(BIN)/tmp |
| + ./hack/gotest.sh browser '$*' |
| + |
| +root no-rastrillo staticcheck govulncheck gitleaks: | $(BIN)/tmp |
| + |
| +$(BIN)/tmp: |
| + mkdir -p "$@" |
diff --git a/ci_test.go b/ci_test.go| new file mode 100644 |
| index 0000000..f9b33eb |
| --- /dev/null |
| +++ b/ci_test.go |
| @@ -0,0 +1,336 @@ |
| +package fiesta |
| + |
| +import ( |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "regexp" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// This file is rastrillo's amadan_ci_test.go, aimed at this repo's own |
| +// tree. .amadan/ci and .amadan/ci.d/ are what a step-reporting runner |
| +// executes, and nothing else gates them: a lost exec bit, or a ci.d |
| +// step that runs its own commands instead of naming a Makefile target, |
| +// resolves amadan's runner to "skipped", which renders as a pass. That |
| +// defect shipped five times in rastrillo before these checks existed; |
| +// Fiesta carries them from its first commit. |
| + |
| +// amadanCIDStepFiles lists .amadan/ci.d's entries, sorted, so every |
| +// check below walks the same list. |
| +func amadanCIDStepFiles(t *testing.T) []string { |
| + t.Helper() |
| + entries, err := os.ReadDir(".amadan/ci.d") |
| + if err != nil { |
| + t.Fatalf("reading .amadan/ci.d: %v", err) |
| + } |
| + var names []string |
| + for _, e := range entries { |
| + if e.IsDir() { |
| + continue |
| + } |
| + names = append(names, e.Name()) |
| + } |
| + sort.Strings(names) |
| + if len(names) == 0 { |
| + t.Fatal(".amadan/ci.d has no step files") |
| + } |
| + return names |
| +} |
| + |
| +// TestAmadanFilesAreExecutableInGit checks the executable bit the way |
| +// amadan's runner sees it: through git's index, not the working tree. |
| +// A filesystem-only check (os.Stat) would pass on a machine where the |
| +// bit is set locally but was never committed — the exact gap that lets |
| +// a non-executable step ship and resolve "skipped" (a pass) on every |
| +// other checkout. |
| +func TestAmadanFilesAreExecutableInGit(t *testing.T) { |
| + out, err := exec.Command("git", "ls-files", "-s", ".amadan/").Output() |
| + if err != nil { |
| + t.Fatalf("git ls-files -s .amadan/: %v", err) |
| + } |
| + lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") |
| + if len(lines) == 0 || lines[0] == "" { |
| + t.Fatal("git ls-files -s .amadan/ reported nothing tracked") |
| + } |
| + |
| + seen := map[string]bool{} |
| + for _, line := range lines { |
| + fields := strings.Fields(line) |
| + if len(fields) < 4 { |
| + t.Fatalf("unexpected `git ls-files -s` line: %q", line) |
| + } |
| + mode, path := fields[0], fields[3] |
| + seen[path] = true |
| + if mode != "100755" { |
| + t.Errorf("%s is committed with mode %s, not 100755 — a runner reading the index (not the working tree) would resolve this job \"skipped\", which renders as a pass", path, mode) |
| + } |
| + } |
| + |
| + want := []string{".amadan/ci"} |
| + for _, name := range amadanCIDStepFiles(t) { |
| + want = append(want, filepath.Join(".amadan/ci.d", name)) |
| + } |
| + for _, w := range want { |
| + if !seen[w] { |
| + t.Errorf("%s is not tracked in git's index at all", w) |
| + } |
| + } |
| +} |
| + |
| +// execMakePattern anchors on the exact shape a ci.d step is allowed to |
| +// take: nothing but a call into the Makefile target of the same name. |
| +var execMakePattern = regexp.MustCompile(`^exec make ([A-Za-z0-9_-]+)$`) |
| + |
| +// amadanCIDTargets reads every .amadan/ci.d step, asserts each is a |
| +// bare `exec make <target>` (the Makefile's ci comment: "ci.d/ ... |
| +// never keeps its own copy of a command"), and returns the |
| +// step-file-name -> target map. |
| +func amadanCIDTargets(t *testing.T) map[string]string { |
| + t.Helper() |
| + targets := map[string]string{} |
| + for _, name := range amadanCIDStepFiles(t) { |
| + path := filepath.Join(".amadan/ci.d", name) |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("reading %s: %v", path, err) |
| + } |
| + var nonBlank []string |
| + for _, l := range strings.Split(string(b), "\n") { |
| + if strings.TrimSpace(l) != "" { |
| + nonBlank = append(nonBlank, l) |
| + } |
| + } |
| + if len(nonBlank) != 2 || nonBlank[0] != "#!/bin/sh" { |
| + t.Errorf("%s must be exactly a `#!/bin/sh` shebang followed by one `exec make <target>` line; got:\n%s", path, b) |
| + continue |
| + } |
| + m := execMakePattern.FindStringSubmatch(nonBlank[1]) |
| + if m == nil { |
| + t.Errorf("%s's step is %q, not a bare `exec make <target>` — a step that runs its own command instead of a Makefile target is exactly the copy the Makefile says never to keep, and it can drift from what `make ci` actually runs without anything noticing", path, nonBlank[1]) |
| + continue |
| + } |
| + targets[name] = m[1] |
| + } |
| + return targets |
| +} |
| + |
| +// TestAmadanStepsAreBareExecMake is that assertion on its own, so a |
| +// broken step file fails here with the specific file and line named, |
| +// rather than only showing up as a set mismatch in the test below. |
| +func TestAmadanStepsAreBareExecMake(t *testing.T) { |
| + amadanCIDTargets(t) |
| +} |
| + |
| +// ciPrereqPattern pulls `ci:`'s prerequisite list out of the Makefile, |
| +// following GNU Make's backslash-newline continuation so the |
| +// multi-line target list is captured whole. |
| +var ciPrereqPattern = regexp.MustCompile(`(?m)^ci:[ \t]*((?:.*\\\n)*.*)$`) |
| + |
| +// TestAmadanCITargetsMatchStepFiles enforces the promise the Makefile |
| +// makes and nothing else checks: add to `ci:` and to ci.d/ together, |
| +// or a step-reporting runner silently skips what you added. A target |
| +// added to `ci:` with no matching ci.d step never runs under amadan's |
| +// runner even though `make ci` covers it; a ci.d step naming a target |
| +// dropped from (or never added to) `ci:` reports a job that `make ci` |
| +// — the one gate — no longer executes. Either drift is silent without |
| +// this. |
| +func TestAmadanCITargetsMatchStepFiles(t *testing.T) { |
| + mk, err := os.ReadFile("Makefile") |
| + if err != nil { |
| + t.Fatalf("reading Makefile: %v", err) |
| + } |
| + m := ciPrereqPattern.FindStringSubmatch(string(mk)) |
| + if m == nil { |
| + t.Fatal("Makefile has no `ci:` target; `make ci` is the one gate every step file execs into") |
| + } |
| + joined := strings.ReplaceAll(m[1], "\\\n", " ") |
| + ciTargets := map[string]bool{} |
| + for _, f := range strings.Fields(joined) { |
| + ciTargets[f] = true |
| + } |
| + if len(ciTargets) == 0 { |
| + t.Fatal("Makefile's `ci:` target has no prerequisites") |
| + } |
| + |
| + stepTargets := map[string]bool{} |
| + for step, target := range amadanCIDTargets(t) { |
| + stepTargets[target] = true |
| + if !ciTargets[target] { |
| + t.Errorf(".amadan/ci.d/%s execs `make %s`, but %q is not one of ci:'s prerequisites in the Makefile — this step now reports a job `make ci` does not run", step, target, target) |
| + } |
| + } |
| + for target := range ciTargets { |
| + if !stepTargets[target] { |
| + t.Errorf("Makefile's `ci:` target depends on %q, but no .amadan/ci.d/* step execs `make %s` — amadan's runner silently skips this half of `make ci`", target, target) |
| + } |
| + } |
| +} |
| + |
| +// TestAmadanSuiteShardsMatchRoles pins the suite step's @N to the |
| +// Makefile's SUITE. amadan starts N shards and each runs the role at |
| +// its index, so the two numbers are one fact written twice: a role |
| +// added without raising N runs nowhere while every shard reports ok. |
| +// hack/suite.sh refuses the mismatch at run time; this says so before |
| +// a runner is involved. It also refuses a role that is a ci |
| +// prerequisite as well, which would run it twice. |
| +func TestAmadanSuiteShardsMatchRoles(t *testing.T) { |
| + if _, err := exec.LookPath("make"); err != nil { |
| + t.Skip("make is not installed; the gate itself runs under make") |
| + } |
| + var step, shards string |
| + for name, target := range amadanCIDTargets(t) { |
| + if target != "suite" { |
| + continue |
| + } |
| + if step != "" { |
| + t.Fatalf("two .amadan/ci.d steps exec `make suite` (%s, %s); each would run every role", step, name) |
| + } |
| + step = name |
| + if i := strings.LastIndex(name, "@"); i >= 0 { |
| + shards = name[i+1:] |
| + } |
| + } |
| + if step == "" { |
| + t.Fatal("no .amadan/ci.d step execs `make suite`, so the suite's roles run nowhere under amadan") |
| + } |
| + |
| + out, err := exec.Command("make", "-s", "--no-print-directory", "suite-roles").Output() |
| + if err != nil { |
| + t.Fatalf("make suite-roles: %v", err) |
| + } |
| + roles := strings.Fields(string(out)) |
| + if len(roles) == 0 { |
| + t.Fatal("make suite-roles printed no roles") |
| + } |
| + if want := strconv.Itoa(len(roles)); shards != want { |
| + t.Errorf(".amadan/ci.d/%s runs as %q shards, but the Makefile's SUITE has %d roles; rename it to end in @%s", step, shards, len(roles), want) |
| + } |
| + |
| + mk, err := os.ReadFile("Makefile") |
| + if err != nil { |
| + t.Fatalf("reading Makefile: %v", err) |
| + } |
| + m := ciPrereqPattern.FindStringSubmatch(string(mk)) |
| + if m == nil { |
| + t.Fatal("Makefile has no `ci:` target") |
| + } |
| + ci := map[string]bool{} |
| + for _, f := range strings.Fields(strings.ReplaceAll(m[1], "\\\n", " ")) { |
| + ci[f] = true |
| + } |
| + seen := map[string]bool{} |
| + for _, r := range roles { |
| + if seen[r] { |
| + t.Errorf("SUITE lists %q twice; two shards would run it", r) |
| + } |
| + seen[r] = true |
| + if ci[r] { |
| + t.Errorf("%q is both a suite role and a prerequisite of ci, so make ci runs it twice", r) |
| + } |
| + } |
| +} |
| + |
| +// sliceRolePattern reads a sliced role: <mode>.<pkg>~<k>of<n>, mode |
| +// browser or test. |
| +var sliceRolePattern = regexp.MustCompile(`^(browser|test)\.(.+)~([0-9]+)of([0-9]+)$`) |
| + |
| +// TestSlicesPartitionTheirPackages is what lets a package run under |
| +// -run at all: a filter that matches nothing exits 0, and a slice is a |
| +// -run too, so this proves the slices cannot leave a test out. Every |
| +// slice 1..n of a package is a suite role, and hack/gotest.sh's deal |
| +// puts each name in exactly one slice and leaves no slice empty. The |
| +// names are every weighted one plus synthetic ones, so this compiles |
| +// nothing; the real list is dealt by the same function at run time. |
| +// It skips while no package is sliced. |
| +func TestSlicesPartitionTheirPackages(t *testing.T) { |
| + if _, err := exec.LookPath("make"); err != nil { |
| + t.Skip("make is not installed; the gate itself runs under make") |
| + } |
| + out, err := exec.Command("make", "-s", "--no-print-directory", "suite-roles").Output() |
| + if err != nil { |
| + t.Fatalf("make suite-roles: %v", err) |
| + } |
| + type sliced struct{ mode, pkg string } |
| + slices := map[sliced]map[int]bool{} |
| + counts := map[sliced]int{} |
| + for _, r := range strings.Fields(string(out)) { |
| + m := sliceRolePattern.FindStringSubmatch(r) |
| + if m == nil { |
| + continue |
| + } |
| + k, _ := strconv.Atoi(m[3]) |
| + n, _ := strconv.Atoi(m[4]) |
| + key := sliced{m[1], strings.ReplaceAll(m[2], ".", "/")} |
| + if prev, ok := counts[key]; ok && prev != n { |
| + t.Fatalf("%s %s is sliced both %d and %d ways", key.mode, key.pkg, prev, n) |
| + } |
| + counts[key] = n |
| + if slices[key] == nil { |
| + slices[key] = map[int]bool{} |
| + } |
| + slices[key][k] = true |
| + } |
| + if len(counts) == 0 { |
| + t.Skip("no package is sliced") |
| + } |
| + |
| + weights, err := os.ReadFile("hack/test-weights.txt") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for key, n := range counts { |
| + name := key.mode + " " + key.pkg |
| + for k := 1; k <= n; k++ { |
| + if !slices[key][k] { |
| + t.Errorf("%s is sliced %d ways but slice %d is no suite role, so its tests run nowhere", name, n, k) |
| + } |
| + } |
| + |
| + var names []string |
| + for _, l := range strings.Split(string(weights), "\n") { |
| + if f := strings.Fields(l); len(f) == 4 && f[0] == key.mode && f[1] == key.pkg { |
| + names = append(names, f[2]) |
| + } |
| + } |
| + for i := 0; i < 5*n; i++ { |
| + names = append(names, "TestUnweighted"+strconv.Itoa(i)) |
| + } |
| + deal := exec.Command("sh", "hack/gotest.sh", "--deal", key.mode, key.pkg, strconv.Itoa(n)) |
| + deal.Stdin = strings.NewReader(strings.Join(names, "\n") + "\n") |
| + got, err := deal.Output() |
| + if err != nil { |
| + t.Fatalf("dealing %s: %v", name, err) |
| + } |
| + dealt := map[string]int{} |
| + used := map[int]bool{} |
| + for _, l := range strings.Split(strings.TrimSpace(string(got)), "\n") { |
| + f := strings.Fields(l) |
| + if len(f) != 2 { |
| + t.Fatalf("deal line %q is not `<k> <name>`", l) |
| + } |
| + k, err := strconv.Atoi(f[0]) |
| + if err != nil || k < 1 || k > n { |
| + t.Errorf("%s: %s was dealt to slice %q, outside 1..%d", name, f[1], f[0], n) |
| + } |
| + used[k] = true |
| + dealt[f[1]]++ |
| + } |
| + for _, nm := range names { |
| + if dealt[nm] != 1 { |
| + t.Errorf("%s: %s was dealt %d times, not once", name, nm, dealt[nm]) |
| + } |
| + } |
| + if len(dealt) != len(names) { |
| + t.Errorf("%s: dealt %d names from %d", name, len(dealt), len(names)) |
| + } |
| + for k := 1; k <= n; k++ { |
| + if !used[k] { |
| + t.Errorf("%s: slice %d of %d was dealt nothing from %d names", name, k, n, len(names)) |
| + } |
| + } |
| + } |
| +} |
diff --git a/doc.go b/doc.go| new file mode 100644 |
| index 0000000..440abae |
| --- /dev/null |
| +++ b/doc.go |
| @@ -0,0 +1,6 @@ |
| +// Package fiesta is Rastrillo's design system as a package of its own: |
| +// the CSS, the optional scripts, the icons, the interface strings and |
| +// the examples a page links or copies, together with the checks that |
| +// keep them as they were. Nothing here imports Rastrillo; Rastrillo |
| +// adopts Fiesta. |
| +package fiesta |
diff --git a/go.mod b/go.mod| new file mode 100644 |
| index 0000000..c7e54a8 |
| --- /dev/null |
| +++ b/go.mod |
| @@ -0,0 +1,3 @@ |
| +module amadan.net/rastrillo/fiesta |
| + |
| +go 1.25.0 |
diff --git a/hack/gotest.sh b/hack/gotest.sh| new file mode 100755 |
| index 0000000..1ab0366 |
| --- /dev/null |
| +++ b/hack/gotest.sh |
| @@ -0,0 +1,187 @@ |
| +#!/bin/sh |
| +# One go test role of the suite. |
| +# |
| +# hack/gotest.sh browser <name> go test -tags browser, one package |
| +# hack/gotest.sh test <name> go test, one package |
| +# hack/gotest.sh root '<split>' go test ./... minus the split packages |
| +# |
| +# <name> is a package path with its slashes written as dots |
| +# (internal.designsystem), optionally ~<k>of<n> to run only the k-th of |
| +# n slices of its tests. |
| +# |
| +# A slice is dealt from the package's own test list: heaviest first, |
| +# each onto the lightest slice so far, by the seconds in |
| +# hack/test-weights.txt (a test not listed there weighs 1). Every shard |
| +# computes the same deal, so the n slices of a package are a partition |
| +# of it: each test runs in exactly one, and a test added later lands in |
| +# a slice without anyone listing it. Two refusals keep that honest. A |
| +# package that lists no tests is a broken build or a broken listing, not |
| +# an empty package; and a slice dealt nothing means n is larger than the |
| +# package, which would report green having run nothing. |
| +# |
| +# hack/gotest.sh --deal <mode> <pkg> <n> reads test names on stdin and |
| +# prints "<k> <name>" for each: the deal alone, which amadan_ci_test.go |
| +# checks is a partition without compiling anything. |
| +set -eu |
| + |
| +weights=$(dirname "$0")/test-weights.txt |
| + |
| +# deal <mode> <pkg> <n>: names on stdin, "<k> <name>" out. Ties sort by |
| +# name, so the deal does not depend on the order the names arrive in. |
| +deal() { |
| + awk -v mode="$1" -v pkg="$2" -v wf="$weights" ' |
| + BEGIN { while ((getline l < wf) > 0) { split(l, f, " "); if (f[1] == mode && f[2] == pkg) w[f[3]] = f[4] } } |
| + NF { print (($1 in w) ? w[$1] : 1), $1 }' | |
| + sort -k1,1nr -k2,2 | |
| + awk -v n="$3" ' |
| + { best = 1; for (i = 2; i <= n; i++) if (load[i] < load[best]) best = i |
| + load[best] += $1; print best, $2 }' |
| +} |
| + |
| +case "${1:-}" in |
| +--deal) |
| + deal "$2" "$3" "$4" |
| + exit 0 |
| + ;; |
| +root) |
| + # Every package but the split ones, which run as roles of their own. |
| + # Each split package must be one go list names: one that was renamed |
| + # or deleted would otherwise drop out of both halves silently. |
| + all=$(go list ./...) |
| + keep=$all |
| + for p in $2; do |
| + ip=$(go list "./${p%@*}") |
| + printf '%s\n' "$all" | grep -qx "$ip" || { |
| + echo "gotest: $ip is split out of root but go list ./... does not name it" |
| + exit 1 |
| + } |
| + keep=$(printf '%s\n' "$keep" | grep -vx "$ip") |
| + done |
| + # shellcheck disable=SC2086 # one argument per package, on purpose |
| + exec go test -count=1 $keep |
| + ;; |
| +browser) |
| + tags="-tags=browser" |
| + timeout=20m |
| + ;; |
| +test) |
| + tags= |
| + timeout=10m |
| + ;; |
| +*) |
| + echo "gotest: usage: gotest.sh browser|test <name> | root '<split>' | --deal <mode> <pkg> <n>" |
| + exit 2 |
| + ;; |
| +esac |
| +mode=$1 |
| + |
| +name=$2 |
| +rel=$name |
| +slice= |
| +case "$name" in |
| +*~*) |
| + rel=${name%%~*} |
| + slice=${name#*~} |
| + ;; |
| +esac |
| +rel=$(printf '%s' "$rel" | tr . /) |
| +pkg=./$rel/ |
| + |
| +# Chromium writes a profile per browser into TMPDIR, and a /tmp shared |
| +# by a whole machine can fill, so browser roles prefer /var/tmp. Only |
| +# where it is writable: amadan's bwrap sandbox mounts the root |
| +# filesystem read-only and gives each job a private tmpfs /tmp, which |
| +# is then the right place. Not inside the checkout, ever: a scratch |
| +# module built under it would find this repo's .git walking up. |
| +if [ "$mode" = browser ] && [ -z "${TMPDIR:-}" ] && [ -w /var/tmp ]; then |
| + TMPDIR=/var/tmp |
| + export TMPDIR |
| +fi |
| + |
| +# The headless shell never restores a page from the back/forward cache, |
| +# and ui's TestBusyButtonDrive needs one (no flag changes that; full |
| +# Chromium of the same version does). Where `chromium` is a headless |
| +# shell, as amadan's cloud runner links it, the packages the Makefile |
| +# names in FULL_CHROMIUM use full Chromium for Testing from beside it |
| +# when it is installed there. Only those: on the fleet full Chromium |
| +# took the design system's slices from minutes to as long as twenty. |
| +# Discovery only: a readlink without -f leaves the browser to the |
| +# harness's own search rather than failing the role. |
| +case " ${FULL_CHROMIUM:-} " in |
| +*" $rel "*) full=yes ;; |
| +*) full= ;; |
| +esac |
| +if [ "$mode" = browser ] && [ -n "$full" ] && [ -z "${FIESTA_CHROME:-}" ] && |
| + shell=$(command -v chromium 2>/dev/null) && shell=$(readlink -f "$shell" 2>/dev/null); then |
| + case "${shell##*/}" in |
| + headless_shell | chrome-headless-shell) |
| + # The same build number as the shell: one Playwright installs both. |
| + build=${shell#*/chromium_headless_shell-} |
| + for c in "${shell%/chromium_headless_shell-*}/chromium-${build%%/*}"/chrome-linux*/chrome; do |
| + if [ -x "$c" ]; then |
| + FIESTA_CHROME=$c |
| + export FIESTA_CHROME |
| + fi |
| + done |
| + ;; |
| + esac |
| +fi |
| + |
| +# Full Chromium's crash handler wants a database under its config |
| +# directory, ~/.config by default, and will not start without one |
| +# ("chrome_crashpad_handler: --database is required"). The cloud |
| +# runner's HOME is read-only, so there it gets a scratch one. |
| +# Which browser a role drove is the first question a red browser role |
| +# raises, and nothing else in the log answers it. |
| +if [ "$mode" = browser ]; then |
| + echo "gotest: on $(uname -n), driving ${FIESTA_CHROME:-the harness's own choice ($(command -v chromium 2>/dev/null || echo none on PATH))}" |
| +fi |
| + |
| +scratch= |
| +if [ "$mode" = browser ] && [ -z "${CHROME_CONFIG_HOME:-}" ] && ! [ -w "${HOME:-/}" ]; then |
| + scratch=$(mktemp -d) |
| + CHROME_CONFIG_HOME=$scratch |
| + export CHROME_CONFIG_HOME |
| +fi |
| + |
| +# run is exec, unless there is scratch to remove after go test. |
| +run() { |
| + if [ -z "$scratch" ]; then |
| + exec "$@" |
| + fi |
| + status=0 |
| + "$@" || status=$? |
| + rm -rf "$scratch" |
| + exit "$status" |
| +} |
| + |
| +if [ -z "$slice" ]; then |
| + # shellcheck disable=SC2086 # an empty $tags is no argument at all |
| + run go test $tags -timeout "$timeout" "$pkg" -count=1 |
| +fi |
| + |
| +k=${slice%of*} |
| +n=${slice#*of} |
| +# Its own command, not the head of a pipeline: a package that fails to |
| +# build must fail here, not list nothing through a grep that succeeds. |
| +# shellcheck disable=SC2086 |
| +listing=$(go test $tags -list '.*' "$pkg") || { |
| + echo "gotest: could not list the tests in $pkg:" |
| + printf '%s\n' "$listing" |
| + exit 1 |
| +} |
| +# Examples and fuzz seeds answer to -run as well, so they are dealt too. |
| +all=$(printf '%s\n' "$listing" | grep -E '^(Test|Example|Fuzz)' || true) |
| +total=$(printf '%s\n' "$all" | grep -c . || true) |
| +if [ "$total" -eq 0 ]; then |
| + echo "gotest: $pkg listed no tests; refusing to report a slice of nothing as a pass" |
| + exit 1 |
| +fi |
| +mine=$(printf '%s\n' "$all" | deal "$mode" "$rel" "$n" | awk -v k="$k" '$1 == k { print $2 }') |
| +if [ -z "$mine" ]; then |
| + echo "gotest: slice $k of $n of $pkg was dealt none of its $total tests; lower its slice count" |
| + exit 1 |
| +fi |
| +echo "gotest: slice $k of $n of $pkg runs $(printf '%s\n' "$mine" | wc -l) of $total tests" |
| +# shellcheck disable=SC2086 |
| +run go test $tags -timeout "$timeout" -run "^($(printf '%s\n' "$mine" | paste -sd'|' -))\$" "$pkg" -count=1 |
diff --git a/hack/suite.sh b/hack/suite.sh| new file mode 100755 |
| index 0000000..c558607 |
| --- /dev/null |
| +++ b/hack/suite.sh |
| @@ -0,0 +1,46 @@ |
| +#!/bin/sh |
| +# Runs the suite's roles (the Makefile's SUITE, passed as arguments). |
| +# |
| +# amadan sets AMADAN_SHARD and AMADAN_SHARD_COUNT on every step, 0 and 1 |
| +# when the step is not sharded, so those two numbers are the whole |
| +# decision: |
| +# |
| +# - unset, or a count of 1: every role, in order, stopping at the |
| +# first failure. That is `make ci` by hand, the single-script |
| +# .amadan/ci fallback, and a suite step that lost its @N. |
| +# - a count equal to the number of roles: this shard's role, alone. |
| +# - any other count: a refusal. A count above the roles would leave |
| +# shards with nothing to do reporting green; one below would leave |
| +# roles that run nowhere while every shard still says ok. |
| +set -eu |
| + |
| +make=${MAKE:-make} |
| +roles=$# |
| +count=${AMADAN_SHARD_COUNT:-1} |
| +shard=${AMADAN_SHARD:-0} |
| + |
| +if [ "$count" -eq 1 ]; then |
| + for role; do |
| + echo "==> make $role" |
| + $make --no-print-directory "$role" |
| + done |
| + exit 0 |
| +fi |
| + |
| +if [ "$count" -ne "$roles" ]; then |
| + echo "suite: started as $count shards, but the Makefile's SUITE has $roles roles." |
| + echo "suite: rename .amadan/ci.d's suite step to end in @$roles, or every role" |
| + echo "suite: past shard $count runs nowhere while each shard still reports ok" |
| + exit 1 |
| +fi |
| + |
| +i=0 |
| +for role; do |
| + if [ "$i" -eq "$shard" ]; then |
| + echo "==> shard $shard of $count: make $role" |
| + exec $make --no-print-directory "$role" |
| + fi |
| + i=$((i + 1)) |
| +done |
| +echo "suite: shard $shard is outside 0..$((count - 1))" |
| +exit 1 |
diff --git a/hack/test-weights.txt b/hack/test-weights.txt| new file mode 100644 |
| index 0000000..b32cf9c |
| --- /dev/null |
| +++ b/hack/test-weights.txt |
| @@ -0,0 +1,8 @@ |
| +# Seconds per test, for hack/gotest.sh's slice deal (<mode> <pkg> |
| +# <test> <seconds>, mode browser or test): the heaviest test goes to |
| +# the lightest slice first. Only tests of 2s or more are worth listing; |
| +# any test not here weighs 1. A stale number costs balance, never |
| +# coverage: every listed test still runs in exactly one slice. Refresh |
| +# from a `go test -v` run: |
| +# grep '^--- ' log | sed -E 's/^--- [A-Z]+: ([^ ]+) \(([0-9.]+)s\)/\1 \2/' |
| +# No package is sliced yet, so there are no weights. |
diff --git a/norastrillo_test.go b/norastrillo_test.go| new file mode 100644 |
| index 0000000..f337916 |
| --- /dev/null |
| +++ b/norastrillo_test.go |
| @@ -0,0 +1,92 @@ |
| +package fiesta |
| + |
| +import ( |
| + "os" |
| + "os/exec" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Fiesta is the design system on its own. Rastrillo adopts it, so a |
| +// path from here back to Rastrillo would be a cycle, and a user who |
| +// links the CSS from a static page has no Go at all. The two tests |
| +// below are the two halves of that promise, executable: no build of |
| +// this module reaches amadan.net/rastrillo/rastrillo, and the packages |
| +// a Go program imports from Fiesta pull in no browser. |
| + |
| +// rastrilloModule is the import path that must never appear in this |
| +// module's dependency graph, and never in its go.mod. |
| +const rastrilloModule = "amadan.net/rastrillo/rastrillo" |
| + |
| +// listDeps runs go list with the given arguments and returns its |
| +// output, one import path per line. A go list that cannot run fails |
| +// the test rather than listing nothing: piped straight into a check, |
| +// an empty listing would pass as clean, which is how a toolchain that |
| +// could not fetch itself once passed rastrillo's own graph check. |
| +func listDeps(t *testing.T, args ...string) []string { |
| + t.Helper() |
| + cmd := exec.Command("go", append([]string{"list"}, args...)...) |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("go list %s: %v\n%s", strings.Join(args, " "), err, out) |
| + } |
| + lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") |
| + if len(lines) == 0 || lines[0] == "" { |
| + t.Fatalf("go list %s printed nothing", strings.Join(args, " ")) |
| + } |
| + return lines |
| +} |
| + |
| +// requireListed is the precondition that keeps either check honest: a |
| +// listing that does not name this module's own package is not a |
| +// listing of this module, whatever else it says. |
| +func requireListed(t *testing.T, lines []string, args string) { |
| + t.Helper() |
| + for _, l := range lines { |
| + if l == "amadan.net/rastrillo/fiesta" { |
| + return |
| + } |
| + } |
| + t.Fatalf("go list %s did not list amadan.net/rastrillo/fiesta itself; the check below would be looking at nothing:\n%s", args, strings.Join(lines, "\n")) |
| +} |
| + |
| +// TestFiestaNeverImportsRastrillo is the global constraint "No |
| +// Rastrillo dependency" as a test: every package of this module, its |
| +// tests included and with the browser tag on (a strict superset of the |
| +// plain build), imports nothing from amadan.net/rastrillo/rastrillo, |
| +// and go.mod never requires it. The go.mod half catches a requirement |
| +// nobody imports yet, which `go list -deps` alone would not see. |
| +func TestFiestaNeverImportsRastrillo(t *testing.T) { |
| + mod, err := os.ReadFile("go.mod") |
| + if err != nil { |
| + t.Fatalf("reading go.mod: %v", err) |
| + } |
| + if strings.Contains(string(mod), rastrilloModule) { |
| + t.Errorf("go.mod mentions %s; Fiesta never requires Rastrillo", rastrilloModule) |
| + } |
| + |
| + args := "-deps -test -tags browser ./..." |
| + lines := listDeps(t, "-deps", "-test", "-tags", "browser", "./...") |
| + requireListed(t, lines, args) |
| + for _, l := range lines { |
| + if strings.HasPrefix(l, rastrilloModule) { |
| + t.Errorf("go list %s reaches %s; Fiesta never imports Rastrillo in any build, tag or test", args, l) |
| + } |
| + } |
| +} |
| + |
| +// TestThePublicPackagesLinkNoBrowser is the other half: the packages |
| +// a Go program imports from Fiesta, the root and (from Task 5) example, |
| +// never link chromedp. The browser is for check/ and the drives, and |
| +// an app that embeds the CSS must not take on a Chrome driver to do |
| +// it. Until example exists the list is the root package alone. |
| +func TestThePublicPackagesLinkNoBrowser(t *testing.T) { |
| + args := "-deps ." |
| + lines := listDeps(t, "-deps", ".") |
| + requireListed(t, lines, args) |
| + for _, l := range lines { |
| + if strings.Contains(l, "chromedp") { |
| + t.Errorf("go list %s reaches %s; the public packages never link a browser", args, l) |
| + } |
| + } |
| +} |
diff --git a/staticcheck.conf b/staticcheck.conf| new file mode 100644 |
| index 0000000..2e33b14 |
| --- /dev/null |
| +++ b/staticcheck.conf |
| @@ -0,0 +1,10 @@ |
| +# staticcheck's default set, minus ST1018. |
| +# |
| +# ST1018 asks for Unicode format characters in string literals to be |
| +# written as escapes. The design system's right-to-left translations |
| +# carry U+200E and U+200F marks on purpose - they decide where a Latin |
| +# brand name sits inside Arabic or Hebrew prose - and as escapes |
| +# those strings stop being readable or editable by the translator who |
| +# owns them. "inherit" keeps every other default, including the ones a |
| +# future staticcheck release adds. |
| +checks = ["inherit", "-ST1018"] |