.PHONY: ci suite suite-roles gofmt no-rastrillo size history root staticcheck govulncheck gitleaks browser heights-sweep site site-links-published site-publish # 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, and the size gate next, so a bundle over the line is the # first thing a red run says. history then makes a runner's shallow # clone whole, once, before the suite's shards share it. Everything # else is the suite. ci: gofmt no-rastrillo size history 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, sliced until every role runs in under a # minute on a runner. The browser packages are a role each, running # only the tests the browser build adds (a package's plain tests run in # root, or in its test. roles), and ROOT_SPLIT's packages leave # root's go test ./... for test. roles. ci_test.go checks that # every test runs in exactly one role. # A package written pkg@n is n roles, each a slice of its tests, dealt # by hack/gotest.sh by the seconds in hack/test-weights.txt. In a # role's name a / in the path is a . so internal/gallery@2 becomes # browser.internal.gallery~1of2 and ~2of2. A test too long for a slice # of its own is dealt into parts by the test itself (the height drives, # the two axe sweeps, the HTML tab's renders, the floors drive). The # tasks that add a browser package append here and rename 50-suite@N # to match. BROWSER_PKGS := internal/harness check@14 internal/gallery@18 internal/gallery/history internal/www@4 ROOT_SPLIT := internal/gallery@3 cmd/fiesta@2 internal/www@2 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)) TEST_ROLES := $(call go-roles,test,$(ROOT_SPLIT)) SUITE := root $(TEST_ROLES) 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 . # size is the one-link promise: each one-file theme the release build # makes from the tree gzips under 25 KiB. One test, run alone and # verbosely so the three figures are in the log of every run; the # test's own package runs again inside root with the rest. size: go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$$' -count=1 -v # 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 # FIESTA_TEST_REQUIRE_GIT does the same for git: the tag guard of # fiesta release -candidate is tested in a scratch repository, and a # runner without git would otherwise pass with the guard untested. FIESTA_TEST_REQUIRE_GIT ?= 1 # FIESTA_TEST_REQUIRE_TAGS does the same for the release tags: # releases_test.go decides what to check from them, and a clone with # none would pass it having compared nothing. make history fetches them # into a runner's shallow clone. FIESTA_TEST_REQUIRE_TAGS ?= 1 TEST_ENV := FIESTA_TEST_REQUIRE_NODE=$(FIESTA_TEST_REQUIRE_NODE) FIESTA_TEST_REQUIRE_GIT=$(FIESTA_TEST_REQUIRE_GIT) FIESTA_TEST_REQUIRE_TAGS=$(FIESTA_TEST_REQUIRE_TAGS) # root builds, vets and tests every package but ROOT_SPLIT's, whose # tests run in the test. roles; `make root $(TEST_ROLES)` is the # old whole. The browser drives are behind the browser tag and run as # roles of their own. root: go build ./... go vet ./... $(TEST_ENV) ./hack/gotest.sh root '$(ROOT_SPLIT)' # test.[~of]: one package of plain tests as a role of its own, # for a package too slow to sit inside root. test.%: | $(BIN)/tmp $(TEST_ENV) ./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) ./... # history makes a shallow clone whole: a runner clones one commit deep # and with no tags, so gitleaks below would scan one commit while # promising the history, and releases_test.go would find no tag to # hold releases/ to. The whole history rather than a range of the # pushed commits: a runner cannot name where a push began, and # Fiesta's history is small (fetching it into a fresh one-commit clone # took a third of a second). Its own step before the suite, because # the suite's shards share one .git and two fetches at once would race # for its locks. On a runner (AMADAN_CI) it also fetches the release # tags into a clone that is already whole, since a tag pushed after a # commit was fetched is not always followed; elsewhere a whole clone # is left alone, so make ci by hand never fetches. history: @if [ "$$(git rev-parse --is-shallow-repository)" = true ]; then \ echo "history: a shallow clone; fetching the whole history and the release tags"; \ git fetch -q --unshallow origin 'refs/tags/v*:refs/tags/v*'; \ elif [ -n "$${AMADAN_CI:-}" ]; then \ git fetch -q origin 'refs/tags/v*:refs/tags/v*'; \ fi @if [ "$$(git rev-parse --is-shallow-repository)" != false ]; then echo "history: the clone is still shallow"; exit 1; fi @echo "history: $$(git rev-list --count HEAD) commits; release tags: $$(git tag -l 'v*' | paste -sd' ' -)" # 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. # A shallow clone is refused rather than scanned, since its history is # one commit; make history comes first in ci for that. # --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: @if [ "$$(git rev-parse --is-shallow-repository)" != false ]; then echo "gitleaks: this clone is shallow, so its history is one commit; run make history first"; exit 1; fi go run $(GITLEAKS) git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' . # The browser drives, one package or slice per browser.[~of] # 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; the gallery's history package holds the legs # that need that restore or a real new tab, and nothing else, so the # cost of full Chromium falls on them alone. export FULL_CHROMIUM := check internal/gallery/history browser.%: | $(BIN)/tmp ./hack/gotest.sh browser '$*' # heights-sweep measures the preview heights table on this machine's # Chromium and fonts and prints it as Go source for heights.go: both # height drives over all twelve locales (FIESTA_SWEEP=full), at once, # each into its own log, printed when both are done. It reports; the # drives still fail a short box. Not in ci: it takes a quarter of an # hour. To calibrate on a runner, push a throwaway branch whose one # commit makes .amadan/ci.d this single step, 10-heights-sweep, being # `exec make heights-sweep`; the table is in that run's log # (amadan ci status rastrillo/fiesta -branch -json). Then delete # the branch. HEIGHTS_SWEEP := FitTheirContent FitAtThePhonesNarrowest heights-sweep: | $(BIN)/tmp @d=$$(mktemp -d) && status=0 && pids= && \ for drive in $(HEIGHTS_SWEEP); do \ FIESTA_SWEEP=full ./hack/gotest.sh browser internal.gallery -timeout 30m -v -run "^TestPreviewFrameHeights$${drive}1of" > "$$d/$$drive.log" 2>&1 & \ pids="$$pids $$!"; \ done; \ for pid in $$pids; do wait "$$pid" || status=1; done; \ for drive in $(HEIGHTS_SWEEP); do echo "==> $$drive"; cat "$$d/$$drive.log"; done; \ rm -rf "$$d"; exit $$status # site assembles fiesta.rastrillo.org into .build/site/public from the # committed releases and the site source: each release under its exact # version, _redirects sending /M/* to the newest M.y.z and the design # system's former addresses to their pages, _headers caching /M/ and # each release for five minutes, and at the root the pages # internal/www assembles from site/, the landing page, the design # system under /design-system/ and site/static/. The tree is # .build/site/public; the command's own .fiesta stamp sits above it in # .build/site, so nothing hidden is in the shipped tree. Nothing in ci # depends on this target, since the releases are committed and checked # there. It is for looking at: the site that ships is site-publish's, # the one build that lets browsers keep a release for a year. site: | $(BIN)/tmp go run ./cmd/fiesta site -releases releases -site site -out .build/site # site-links-published is the ship check for links: the commit # SITE_REF names (HEAD unless told), exported with git archive, so # nothing uncommitted or untracked in the checkout counts, and its # site's every link resolved as the edge will serve that tree, with # /1/ reaching the newest release committed under releases/ and # nothing else (FIESTA_LINKS_PUBLISHED=1). The suite's run of the same # test lets a /1/ link reach the release the source would build while # that release is uncut, so it passes a link that 404s the day the # site ships ahead of its release; this does not. Not in ci, for that # reason: it fails from a change to js/, css/ or i18n/ until the # release carrying it is committed, as it should. Run it, and see it # pass, before shipping the site. # # Each step that can fail ends the recipe with its own failure, and the # scratch tree goes in an EXIT trap. A status kept in a variable through # an && chain came out 0 when the export failed, so a bad SITE_REF # passed with no link checked. The archive is written to a file rather # than piped, so a failing git archive is not hidden behind tar's # status. The trap keeps the status it was given, and turns a pass into # a failure when the tree cannot be removed: a trap whose own status is # dropped reported success with a checked-out site left in TMPDIR. SITE_REF ?= HEAD site-links-published: | $(BIN)/tmp @d=$$(mktemp -d) || exit 1; \ trap 's=$$?; rm -rf "$$d" || { echo "site-links-published: could not remove $$d"; \ [ $$s -ne 0 ] || s=1; }; exit $$s' EXIT; \ trap 'exit 130' INT TERM; \ git archive --format=tar -o "$$d/site.tar" '$(SITE_REF)' || { \ echo "site-links-published: git archive could not export $(SITE_REF)"; exit 1; }; \ mkdir "$$d/tree" && tar -x -C "$$d/tree" -f "$$d/site.tar" || { \ echo "site-links-published: could not extract $(SITE_REF)'s tree"; exit 1; }; \ if ! grep -q FIESTA_LINKS_PUBLISHED "$$d/tree/internal/www/links_test.go"; then \ echo "site-links-published: $(SITE_REF)'s link check has no published-only mode, so it would pass through the fallback; check a commit that has it"; exit 1; \ fi; \ cd "$$d/tree" && FIESTA_LINKS_PUBLISHED=1 go test ./internal/www -run '^TestEveryLinkInTheSiteResolves$$' -count=1 -v # site-publish builds the site that ships, from a release tag: # make site-publish SITE_REF=vX.Y.Z, then carlos ship # .build/publish/public. It is the one build that tells browsers to # keep a release for a year as immutable. Every other build keeps each # release five minutes, because until its tag exists a release may be # rebuilt as a candidate under the same name, and a browser told it # never changes would hold the old bytes for a year. # # It refuses, before building anything, unless SITE_REF is an # annotated tag vX.Y.Z here, origin holds the same tag, the tag's tree # has releases/X.Y.Z/ and /X/ would serve it, and the tag's fiesta site # can be told what is published; then it runs site-links-published on # the same tag, so the links are checked against what ships. It builds # from git archive of the tag, as site-links-published does, with the # tag's own fiesta site, and names every release directory in the # tag's tree as published. fiesta site checks each against its tag # vX.Y.Z here, file for file (site.TagDifferences, which # TestPublishedReleasesMatchTheirTags runs on every push), and writes # nothing if one differs. A tag from before -published existed is # refused, rather than shipped with no _headers. The two targets sit # together this way: site-links-published stays the check to run on # any ref before tagging, and site-publish runs it again on the tag as # its own first build step, so nothing ships unchecked. # # Each step that can fail ends the recipe with its own failure, and # the export goes in an EXIT trap, as in site-links-published. The # built site goes to .build/publish, outside the export, so it # outlives the trap for carlos ship to read; a run that fails after # building removes .build/publish/public, so a refused site is never # there to ship. PUBLISH_OUT := $(BIN)/publish site-publish: | $(BIN)/tmp @ref='$(SITE_REF)'; \ printf '%s\n' "$$ref" | grep -Eqx 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' || { \ echo "site-publish: SITE_REF=$$ref is not a release tag vX.Y.Z; name the tag the site ships from"; exit 1; }; \ version=$${ref#v}; major=$${version%%.*}; \ [ "$$(git cat-file -t "refs/tags/$$ref" 2>/dev/null)" = tag ] || { \ echo "site-publish: $$ref is not an annotated tag here; a release is tagged with git tag -a and pushed"; exit 1; }; \ here=$$(git rev-parse "refs/tags/$$ref") || exit 1; \ there=$$(GIT_TERMINAL_PROMPT=0 git ls-remote --tags origin "refs/tags/$$ref") || { \ echo "site-publish: could not ask origin for $$ref"; exit 1; }; \ [ "$${there%%[[:space:]]*}" = "$$here" ] || { \ echo "site-publish: origin does not hold this $$ref ($$here); push the tag, or fetch origin's"; exit 1; }; \ d=$$(mktemp -d) || exit 1; \ trap 's=$$?; rm -rf "$$d" || { echo "site-publish: could not remove $$d"; \ [ $$s -ne 0 ] || s=1; }; exit $$s' EXIT; \ trap 'exit 130' INT TERM; \ git archive --format=tar -o "$$d/site.tar" "refs/tags/$$ref" || { \ echo "site-publish: git archive could not export $$ref"; exit 1; }; \ mkdir "$$d/tree" && tar -x -C "$$d/tree" -f "$$d/site.tar" || { \ echo "site-publish: could not extract $$ref's tree"; exit 1; }; \ [ -d "$$d/tree/releases/$$version" ] || { \ echo "site-publish: $$ref holds no releases/$$version, so the site would not serve the release it is shipped for"; exit 1; }; \ newest=$$(ls "$$d/tree/releases" | grep -E "^$$major\.[0-9]+\.[0-9]+$$" | sort -t. -k2,2n -k3,3n | tail -n 1); \ [ "$$newest" = "$$version" ] || { \ echo "site-publish: /$$major/ would serve $$newest, not $$version; ship the tag of the newest $$major.x"; exit 1; }; \ grep -q '"published"' "$$d/tree/cmd/fiesta/main.go" || { \ echo "site-publish: $$ref's fiesta site has no -published, so it cannot tell browsers a release never changes; ship a tag that has it"; exit 1; }; \ published=$$(ls "$$d/tree/releases" | paste -sd, -); \ repo=$$(git rev-parse --show-toplevel) || exit 1; \ $(MAKE) --no-print-directory site-links-published SITE_REF="refs/tags/$$ref" || { \ echo "site-publish: the links of $$ref's site do not all resolve against its published releases"; exit 1; }; \ echo "site-publish: building $$ref with $$published published"; \ (cd "$$d/tree" && go run -buildvcs=false ./cmd/fiesta site -releases releases -site site \ -out '$(PUBLISH_OUT)' -published "$$published" -git "$$repo") || { \ echo "site-publish: fiesta site refused $$ref"; exit 1; }; \ grep -qxF "/$$major/* /$$version/:splat 200" '$(PUBLISH_OUT)/public/_redirects' || { \ rm -rf '$(PUBLISH_OUT)/public'; \ echo "site-publish: the built _redirects does not send /$$major/ to $$version; removed $(PUBLISH_OUT)/public"; exit 1; }; \ echo "site-publish: $(PUBLISH_OUT)/public is $$ref's site, $$published kept for a year; ship that tree" root no-rastrillo size staticcheck govulncheck gitleaks: | $(BIN)/tmp $(BIN)/tmp: mkdir -p "$@"