rastrillo / fiesta Public

Assemble the versioned site: exact versions and a /1/ rewrite nothing can shadow

internal/site.Build assembles fiesta.rastrillo.org from two trees:
the committed releases, one X.Y.Z/ each, and the pages. Every
release is copied under its exact version after Release has checked
each file against the release's SHA256SUMS: a flipped byte, a file
the sums do not list, a listed file that is gone, or a missing sums
file each refuse the whole build and name the file. A directory not
named strictly X.Y.Z (a prerelease, a v prefix, a leading zero) or a
loose file at the top of releases/ is refused the same way.

_redirects gets one rule per major, newest major first, sending /M/*
to that major's newest release with two spaces between fields:
/2/*  /2.0.0/:splat  200. The newest release is chosen numerically,
so 1.10.0 beats 1.9.3. TestTwoDoesNotMoveOne compares the whole
file, so the format is pinned, and TestNewestIsNumericNotLexical
pins the ordering.

The pages are copied to the root, except three kinds that CARLOS's
edge would serve before the rewrite, since a real file at the request
path wins over a rule that is not forced: a page whose first segment
is all digits (which reserves /1/, /7/ and so on, released or not), a
page whose first segment is shaped like a version, and a page named
_redirects. Each is refused by name.

fiesta release -version X.Y.Z [-releases DIR] [-candidate] writes
build.Dist into releases/X.Y.Z/, with no stamp, since the directory
is committed and published as is. The version must be fiesta.Version.
An existing directory is refused, because a committed release never
changes, unless -candidate is passed and three things hold: no tag
vX.Y.Z locally (git rev-parse -q --verify), none on origin (git
ls-remote --tags), and the directory is a release Fiesta wrote, which
site.Release decides from its own SHA256SUMS. A git that cannot
answer is a refusal. That check stands in for claim's stamp as the
reason the command may empty the directory; claim.go is unchanged.
The dist is also held to build.CheckSize before it is written.

fiesta site -releases DIR -pages DIR -out DIR writes Build's tree
into a directory it claims as "fiesta site", and make site runs it
into .build/site. The subcommands share one flag parser now.

Every test was red by name against a stub Build returning an empty
map and no release or site subcommand: the eight in internal/site
(TestOneServesTheNewestOneX, TestNewestIsNumericNotLexical,
TestTwoDoesNotMoveOne, TestAChangedReleaseIsRefused,
TestAPrereleaseDirectoryIsRefused, TestPagesCannotShadowAMajor,
TestPagesCannotShadowAVersion, TestWhatBuildWritesPassesTheCheck)
and the three in cmd/fiesta/release_test.go
(TestReleaseRefusesToOverwrite,
TestACandidateMayBeRebuiltUntilItIsTagged,
TestSiteWritesWhatBuildReturns). Three scratch mutations were run
as well: one space between the rule's fields reddens the three
_redirects tests, skipping the origin tag check reddens the
candidate test at its origin-only step, and letting a page start
with digits reddens TestPagesCannotShadowAMajor.

The candidate test builds a scratch repository with a bare origin
beside it under the test's temp directory, so pushing the tag never
leaves the machine; its own git runs with no user or system config.

TestTheRedirectsStayUnderCarlosLimit from the plan is not written:
one line per major cannot reach the edge's 2,000-rule limit, so it
could not fail. The exact-bytes comparison in TestTwoDoesNotMoveOne
stands in its place.
Human

Prompt

Implement the versioned site and the two CARLOS requests: internal/site, fiesta release and fiesta site, the Makefile site target, with the tests written first against stubs. Phase A: draft the page and the request bodies for copy review and commit only the code and tests.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

Adds internal/site (Build, Release), the fiesta release and fiesta site subcommands, the make site target, and their tests.
Agent notes
site/index.html and the CARLOS posts wait for Paul's copy review (Phase B). releases/1.0.0 is cut in the next task. claim.go is unchanged; release carries no stamp and uses its SHA256SUMS as the ownership check instead.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 93513901b6dead9b0bf5ad3f7e59352eb030aca7 parent aa5ccb6
5 files changed, +1057 −28
  • Makefile +9 −1
  • cmd/fiesta/main.go +201 −27
  • cmd/fiesta/release_test.go +336 −0
  • internal/site/site.go +230 −0
  • internal/site/site_test.go +281 −0

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.095s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN   TestTheOneLinkIsUnder25KiBGzipped
    build_test.go:137: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
    build_test.go:137: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
    build_test.go:137: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.021s
=== suite ===
--- shard 0 of 10 ---
==> shard 0 of 10: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 go test -count=1 ./...
ok  	amadan.net/rastrillo/fiesta	0.436s
ok  	amadan.net/rastrillo/fiesta/check	3.518s
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	3.721s
ok  	amadan.net/rastrillo/fiesta/example	0.172s
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.066s
ok  	amadan.net/rastrillo/fiesta/internal/build	0.911s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.012s
ok  	amadan.net/rastrillo/fiesta/internal/gallery	1.940s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.005s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.331s
ok  	amadan.net/rastrillo/fiesta/internal/site	0.042s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.020s
--- shard 1 of 10 ---
==> shard 1 of 10: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 10 ---
==> shard 2 of 10: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===

No vulnerabilities found.

Your code is affected by 0 vulnerabilities.
This scan also found 0 vulnerabilities in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
--- shard 3 of 10 ---
==> shard 3 of 10: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
9:04PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
9:04PM INF 1 commits scanned.
9:04PM INF scanned ~4531919 bytes (4.53 MB) in 2.49s
9:04PM INF no leaks found
--- shard 4 of 10 ---
==> shard 4 of 10: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok  	amadan.net/rastrillo/fiesta/internal/harness	9.098s
--- shard 5 of 10 ---
==> shard 5 of 10: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 92 of 276 tests
ok  	amadan.net/rastrillo/fiesta/check	190.351s
--- shard 6 of 10 ---
==> shard 6 of 10: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 92 of 276 tests
ok  	amadan.net/rastrillo/fiesta/check	195.135s
--- shard 7 of 10 ---
==> shard 7 of 10: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 92 of 276 tests
ok  	amadan.net/rastrillo/fiesta/check	193.468s
--- shard 8 of 10 ---
==> shard 8 of 10: make browser.internal.gallery~1of2
./hack/gotest.sh browser 'internal.gallery~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/gallery/ runs 1 of 13 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	67.034s
--- shard 9 of 10 ---
==> shard 9 of 10: make browser.internal.gallery~2of2
./hack/gotest.sh browser 'internal.gallery~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/gallery/ runs 12 of 13 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	18.949s
diff --git a/Makefile b/Makefile
index ecd896e..e11c0f1 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: ci suite suite-roles gofmt no-rastrillo size root staticcheck govulncheck gitleaks browser
+.PHONY: ci suite suite-roles gofmt no-rastrillo size root staticcheck govulncheck gitleaks browser site
# CGO_ENABLED=0 across the tree is an acceptance criterion, not a
# preference: Fiesta is CSS, scripts and the Go that embeds and checks
@@ -182,6 +182,14 @@ export FULL_CHROMIUM := check
browser.%: | $(BIN)/tmp
./hack/gotest.sh browser '$*'
+# site assembles fiesta.rastrillo.org into .build/site from the
+# committed releases and the pages: each release under its exact
+# version, _redirects sending /M/* to the newest M.y.z, and site/ at
+# the root. What it writes is what gets shipped; nothing in ci depends
+# on it, since the releases are committed and checked there.
+site: | $(BIN)/tmp
+ go run ./cmd/fiesta site -releases releases -pages site -out .build/site
+
root no-rastrillo size staticcheck govulncheck gitleaks: | $(BIN)/tmp
$(BIN)/tmp:
diff --git a/cmd/fiesta/main.go b/cmd/fiesta/main.go
index f2d107a..8dcd035 100644
--- a/cmd/fiesta/main.go
+++ b/cmd/fiesta/main.go
@@ -1,4 +1,4 @@
-// Command fiesta is the design system's command line. Two subcommands.
+// Command fiesta is the design system's command line. Four subcommands.
//
// fiesta build -out DIR
//
@@ -17,11 +17,30 @@
// development and test artefact, for looking at; nothing publishes
// it.
//
-// Each owns the directory it writes. It empties it before it writes,
-// so what is there afterwards is the output and only the output, and
-// it will only take a directory that is absent, empty, or marked with
-// its own stamp (.fiesta); anything else it refuses, changing
-// nothing. See claim.
+// fiesta release -version X.Y.Z [-releases DIR] [-candidate]
+//
+// writes the same release build makes into DIR/X.Y.Z/ (DIR is
+// releases/ by default), to be committed: the releases directory is
+// what fiesta site publishes. The version must be fiesta.Version, the
+// one in the tree. A directory that already exists is refused, since a
+// committed release never changes, unless -candidate is passed and no
+// tag vX.Y.Z exists locally or on origin: an unpublished candidate may
+// be rebuilt until it is tagged. See releaseCmd.
+//
+// fiesta site -releases DIR -pages DIR -out DIR
+//
+// assembles the site from the releases and the pages, exactly what
+// internal/site.Build returns, into -out: each release under its exact
+// version, _redirects sending /M/* to the newest M.y.z, and the pages
+// at the root.
+//
+// build, gallery and site each own the directory they write. They
+// empty it before they write, so what is there afterwards is the
+// output and only the output, and they will only take a directory that
+// is absent, empty, or marked with their own stamp (.fiesta); anything
+// else they refuse, changing nothing. See claim. release writes no
+// stamp, since its output is committed and published as is; its
+// ownership check is the release's own SHA256SUMS.
package main
import (
@@ -30,15 +49,19 @@ import (
"fmt"
"io"
"os"
+ "os/exec"
"path/filepath"
"sort"
+ "strings"
"amadan.net/rastrillo/fiesta"
"amadan.net/rastrillo/fiesta/internal/build"
"amadan.net/rastrillo/fiesta/internal/gallery"
+ "amadan.net/rastrillo/fiesta/internal/site"
)
-const usage = "usage: fiesta build -out DIR | fiesta gallery -out DIR [-mount /path]"
+const usage = "usage: fiesta build -out DIR | fiesta gallery -out DIR [-mount /path] | " +
+ "fiesta release -version X.Y.Z [-releases DIR] [-candidate] | fiesta site -releases DIR -pages DIR -out DIR"
func main() {
if err := run(os.Args[1:], os.Stderr); err != nil {
@@ -58,27 +81,41 @@ func run(args []string, stderr io.Writer) error {
return buildCmd(args[1:], stderr)
case "gallery":
return galleryCmd(args[1:], stderr)
+ case "release":
+ return releaseCmd(args[1:], stderr)
+ case "site":
+ return siteCmd(args[1:], stderr)
default:
return fmt.Errorf("unknown command %q\n%s", args[0], usage)
}
}
-// buildCmd is the build subcommand's flags, then write.
-func buildCmd(args []string, stderr io.Writer) error {
- flags := flag.NewFlagSet("build", flag.ContinueOnError)
+// parse runs a subcommand's flag set over args: help goes to stderr
+// and returns false with no error, a bad flag or a stray argument is
+// an error, and true means the flags are set and the command may run.
+func parse(flags *flag.FlagSet, args []string, stderr io.Writer) (bool, error) {
flags.SetOutput(io.Discard)
- out := flags.String("out", "", "directory to write the release into, under DIR/<version>/")
if err := flags.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
flags.SetOutput(stderr)
fmt.Fprintln(stderr, usage)
flags.PrintDefaults()
- return nil
+ return false, nil
}
- return err
+ return false, err
}
if flags.NArg() > 0 {
- return fmt.Errorf("unexpected argument %q\n%s", flags.Arg(0), usage)
+ return false, fmt.Errorf("unexpected argument %q\n%s", flags.Arg(0), usage)
+ }
+ return true, nil
+}
+
+// buildCmd is the build subcommand's flags, then write.
+func buildCmd(args []string, stderr io.Writer) error {
+ flags := flag.NewFlagSet("build", flag.ContinueOnError)
+ out := flags.String("out", "", "directory to write the release into, under DIR/<version>/")
+ if ok, err := parse(flags, args, stderr); !ok {
+ return err
}
if *out == "" {
return errors.New("-out is required\n" + usage)
@@ -89,27 +126,49 @@ func buildCmd(args []string, stderr io.Writer) error {
// galleryCmd is the gallery subcommand's flags, then write.
func galleryCmd(args []string, stderr io.Writer) error {
flags := flag.NewFlagSet("gallery", flag.ContinueOnError)
- flags.SetOutput(io.Discard)
out := flags.String("out", "", "directory to write the gallery into")
mount := flags.String("mount", "", "URL path the gallery will be served from (default the site root)")
- if err := flags.Parse(args); err != nil {
- if errors.Is(err, flag.ErrHelp) {
- flags.SetOutput(stderr)
- fmt.Fprintln(stderr, usage)
- flags.PrintDefaults()
- return nil
- }
+ if ok, err := parse(flags, args, stderr); !ok {
return err
}
- if flags.NArg() > 0 {
- return fmt.Errorf("unexpected argument %q\n%s", flags.Arg(0), usage)
- }
if *out == "" {
return errors.New("-out is required\n" + usage)
}
return writeGallery(*out, *mount)
}
+// releaseCmd is the release subcommand's flags, then release.
+func releaseCmd(args []string, stderr io.Writer) error {
+ flags := flag.NewFlagSet("release", flag.ContinueOnError)
+ version := flags.String("version", "", "the version to release, which must be the tree's own ("+fiesta.Version+")")
+ releases := flags.String("releases", "releases", "the directory the release is written under, as DIR/<version>/")
+ candidate := flags.Bool("candidate", false, "replace an existing, untagged release directory whole")
+ if ok, err := parse(flags, args, stderr); !ok {
+ return err
+ }
+ if *version == "" {
+ return errors.New("-version is required\n" + usage)
+ }
+ return release(*releases, *version, *candidate)
+}
+
+// siteCmd is the site subcommand's flags, then writeSite.
+func siteCmd(args []string, stderr io.Writer) error {
+ flags := flag.NewFlagSet("site", flag.ContinueOnError)
+ releases := flags.String("releases", "", "the directory of committed releases, one X.Y.Z/ each")
+ pages := flags.String("pages", "", "the directory of pages for the site's root")
+ out := flags.String("out", "", "directory to write the site into")
+ if ok, err := parse(flags, args, stderr); !ok {
+ return err
+ }
+ for name, value := range map[string]string{"-releases": *releases, "-pages": *pages, "-out": *out} {
+ if value == "" {
+ return errors.New(name + " is required\n" + usage)
+ }
+ }
+ return writeSite(*releases, *pages, *out)
+}
+
// write builds the release and puts it under out/<version>/, a
// directory it claims first. Separate from the flags so a test can
// call it with a directory of its own.
@@ -131,8 +190,117 @@ func writeGallery(out, mount string) error {
return writeTree(out, "fiesta gallery", files)
}
-// writeTree claims root for command and writes files under it, in
-// name order.
+// writeSite assembles the site from releases and pages and puts it in
+// out, a directory it claims first.
+func writeSite(releases, pages, out string) error {
+ files, err := site.Build(os.DirFS(releases), os.DirFS(pages))
+ if err != nil {
+ return err
+ }
+ return writeTree(out, "fiesta site", files)
+}
+
+// release builds the dist at version and writes it to releases/<version>/.
+//
+// The version must be fiesta.Version: a release is the tree as it is,
+// and the banner in every file says which version that is. The dist is
+// also held to the one-link size limit here, so a release over it is
+// never cut, whatever the gate said.
+//
+// The directory is the committed, published copy, so an existing one
+// is refused: a release that has gone out never changes. The one
+// exception is -candidate, for a release that has been built and
+// committed but not yet tagged, which may be rebuilt after a fix. It
+// is allowed only while every one of these holds, and refused by name
+// otherwise:
+//
+// - no tag v<version> exists locally (git rev-parse) or on origin
+// (git ls-remote), so nothing has been published under the name;
+// a git that cannot answer is a refusal, not an absence;
+// - the directory is a release Fiesta wrote: every file in it is a
+// regular file listed in its SHA256SUMS with matching bytes, and
+// every listed file is present (site.Release). That is the
+// ownership check, in place of the stamp build and gallery use,
+// because a release is published as is and carries no stamp.
+//
+// An empty directory is taken either way, since there is nothing in it
+// to lose. A candidate that passes is removed whole and rewritten, so
+// a file the new dist no longer has is gone too, and the directory is
+// the dist, every time.
+func release(releases, version string, candidate bool) error {
+ if version != fiesta.Version {
+ return fmt.Errorf("the tree is version %s, so that is the only release it can build; -version %s was asked for", fiesta.Version, version)
+ }
+ dist, err := build.Dist(fiesta.FS(), version)
+ if err != nil {
+ return err
+ }
+ if err := build.CheckSize(dist); err != nil {
+ return err
+ }
+ root, err := filepath.Abs(filepath.Join(releases, version))
+ if err != nil {
+ return fmt.Errorf("resolving -releases: %w", err)
+ }
+ entries, err := os.ReadDir(root)
+ switch {
+ case os.IsNotExist(err):
+ case err != nil:
+ return fmt.Errorf("reading %s: %w", root, err)
+ case len(entries) > 0:
+ if !candidate {
+ return fmt.Errorf("refusing to replace %s: a committed release never changes. "+
+ "If it is a candidate that has not been tagged, pass -candidate to rebuild it", root)
+ }
+ tag := "v" + version
+ if tagged, err := tagExists(filepath.Dir(filepath.Dir(root)), tag); err != nil {
+ return fmt.Errorf("refusing to replace %s: could not tell whether %s is tagged: %w", root, tag, err)
+ } else if tagged {
+ return fmt.Errorf("refusing to replace %s: the tag %s exists, so it has been released. "+
+ "A fix is the next version", root, tag)
+ }
+ if _, err := site.Release(os.DirFS(root)); err != nil {
+ return fmt.Errorf("refusing to replace %s: it is not a release fiesta wrote, so it is not this command's to empty: %s/%v. "+
+ "Empty it yourself, or point -releases somewhere else", root, root, err)
+ }
+ if err := os.RemoveAll(root); err != nil {
+ return fmt.Errorf("emptying %s: %w", root, err)
+ }
+ }
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ return fmt.Errorf("creating %s: %w", root, err)
+ }
+ return writeFiles(root, dist)
+}
+
+// tagExists reports whether tag is a tag of the repository holding
+// dir, locally or on its origin. Either git failing to answer is an
+// error: the caller is about to empty a directory on the strength of
+// the answer, and "I could not check" is not "no".
+func tagExists(dir, tag string) (bool, error) {
+ local := exec.Command("git", "-C", dir, "rev-parse", "-q", "--verify", "refs/tags/"+tag)
+ local.Stdout, local.Stderr = io.Discard, io.Discard
+ err := local.Run()
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ return true, nil
+ case errors.As(err, &exit) && exit.ExitCode() == 1:
+ // rev-parse -q --verify exits 1 for a ref that does not exist.
+ default:
+ return false, fmt.Errorf("git rev-parse: %w", err)
+ }
+ remote := exec.Command("git", "-C", dir, "ls-remote", "--tags", "origin", tag)
+ var stderr strings.Builder
+ remote.Stderr = &stderr
+ out, err := remote.Output()
+ if err != nil {
+ return false, fmt.Errorf("git ls-remote --tags origin: %w: %s", err, strings.TrimSpace(stderr.String()))
+ }
+ return strings.TrimSpace(string(out)) != "", nil
+}
+
+// writeTree claims root for command and writes files under it.
func writeTree(root, command string, files map[string][]byte) error {
root, err := filepath.Abs(root)
if err != nil {
@@ -141,6 +309,12 @@ func writeTree(root, command string, files map[string][]byte) error {
if err := claim(root, command); err != nil {
return err
}
+ return writeFiles(root, files)
+}
+
+// writeFiles writes files under root, in name order, creating the
+// directories they need.
+func writeFiles(root string, files map[string][]byte) error {
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
diff --git a/cmd/fiesta/release_test.go b/cmd/fiesta/release_test.go
new file mode 100644
index 0000000..773895f
--- /dev/null
+++ b/cmd/fiesta/release_test.go
@@ -0,0 +1,336 @@
+package main
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/internal/build"
+ "amadan.net/rastrillo/fiesta/internal/site"
+)
+
+// fiestaRelease runs `fiesta release -version <version> -releases
+// <releases> [-candidate]` as main would, and returns its error.
+func fiestaRelease(t *testing.T, releases, version string, candidate bool) error {
+ t.Helper()
+ args := []string{"release", "-version", version, "-releases", releases}
+ if candidate {
+ args = append(args, "-candidate")
+ }
+ return run(args, io.Discard)
+}
+
+// dist is the release the command should write at fiesta.Version.
+func dist(t *testing.T) map[string][]byte {
+ t.Helper()
+ d, err := build.Dist(fiesta.FS(), fiesta.Version)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return d
+}
+
+// assertIsTheDist checks that dir holds the dist file for file and
+// nothing else: no stamp, no leftovers.
+func assertIsTheDist(t *testing.T, dir string, d map[string][]byte) {
+ t.Helper()
+ for name, want := range d {
+ got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name)))
+ if err != nil {
+ t.Errorf("%s was not written: %v", name, err)
+ continue
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("%s on disk differs from the dist", name)
+ }
+ }
+ var extra []string
+ err := filepath.WalkDir(dir, func(p string, e os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if e.IsDir() {
+ return nil
+ }
+ rel, _ := filepath.Rel(dir, p)
+ if _, ok := d[filepath.ToSlash(rel)]; !ok {
+ extra = append(extra, rel)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(extra) > 0 {
+ t.Errorf("%s holds files that are not in the dist: %s", dir, strings.Join(extra, ", "))
+ }
+}
+
+// TestReleaseRefusesToOverwrite: a version other than fiesta.Version
+// is refused and nothing is created; the first run writes the dist,
+// file for file, with no stamp; a second run finds the directory and
+// refuses it, leaving it exactly as it was.
+func TestReleaseRefusesToOverwrite(t *testing.T) {
+ releases := filepath.Join(t.TempDir(), "releases")
+ err := fiestaRelease(t, releases, "9.9.9", false)
+ if err == nil {
+ t.Fatal("fiesta release built 9.9.9, which is not fiesta.Version")
+ }
+ if !strings.Contains(err.Error(), fiesta.Version) {
+ t.Errorf("the refusal does not name the version it would build, %s: %v", fiesta.Version, err)
+ }
+ if _, err := os.Stat(releases); !os.IsNotExist(err) {
+ t.Errorf("a refused release created %s: %v", releases, err)
+ }
+
+ if err := fiestaRelease(t, releases, fiesta.Version, false); err != nil {
+ t.Fatalf("fiesta release into an absent directory: %v", err)
+ }
+ dir := filepath.Join(releases, fiesta.Version)
+ assertIsTheDist(t, dir, dist(t))
+
+ css := filepath.Join(dir, "fiesta.css")
+ if err := os.WriteFile(css, []byte("mine"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ err = fiestaRelease(t, releases, fiesta.Version, false)
+ if err == nil {
+ t.Fatalf("fiesta release overwrote %s, which already exists", dir)
+ }
+ if !strings.Contains(err.Error(), dir) {
+ t.Errorf("the refusal does not name %s: %v", dir, err)
+ }
+ if !strings.Contains(err.Error(), "-candidate") {
+ t.Errorf("the refusal does not say how a candidate may be replaced: %v", err)
+ }
+ if b, err := os.ReadFile(css); err != nil || string(b) != "mine" {
+ t.Errorf("the refused directory was touched: fiesta.css is %q, %v", b, err)
+ }
+}
+
+// git runs git in dir for the test's own setup, with no user or system
+// configuration so the machine's settings cannot change what it does,
+// and fails the test on error.
+func git(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ cmd := exec.Command("git", append([]string{"-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "init.defaultBranch=main"}, args...)...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return string(out)
+}
+
+// scratchRepo is a git repository with one commit and an origin that
+// is a bare repository beside it, so pushing a tag never leaves the
+// machine.
+func scratchRepo(t *testing.T) string {
+ t.Helper()
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git is not installed")
+ }
+ root := t.TempDir()
+ repo := filepath.Join(root, "repo")
+ origin := filepath.Join(root, "origin.git")
+ if err := os.Mkdir(repo, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ git(t, root, "init", "-q", "--bare", origin)
+ git(t, repo, "init", "-q")
+ if err := os.WriteFile(filepath.Join(repo, "README"), []byte("scratch\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, repo, "add", "README")
+ git(t, repo, "commit", "-q", "-m", "scratch")
+ git(t, repo, "remote", "add", "origin", origin)
+ git(t, repo, "push", "-q", "origin", "main")
+ return repo
+}
+
+// TestACandidateMayBeRebuiltUntilItIsTagged: -candidate replaces an
+// existing release directory whole, but only while it is a release
+// fiesta wrote and no tag vX.Y.Z names it, locally or on origin. A
+// stray file or a link inside, a local tag, or a tag on origin alone,
+// each refuses the rebuild and leaves the directory as it was.
+func TestACandidateMayBeRebuiltUntilItIsTagged(t *testing.T) {
+ repo := scratchRepo(t)
+ releases := filepath.Join(repo, "releases")
+ dir := filepath.Join(releases, fiesta.Version)
+ d := dist(t)
+ tag := "v" + fiesta.Version
+
+ if err := fiestaRelease(t, releases, fiesta.Version, true); err != nil {
+ t.Fatalf("fiesta release -candidate into an absent directory: %v", err)
+ }
+ assertIsTheDist(t, dir, d)
+
+ // An older candidate, built from a tree without NOTICE: still a
+ // self-consistent release, and not what the tree builds now.
+ older := func() {
+ t.Helper()
+ if err := os.Remove(filepath.Join(dir, "NOTICE")); err != nil {
+ t.Fatal(err)
+ }
+ var kept []string
+ for _, line := range strings.SplitAfter(string(d["SHA256SUMS"]), "\n") {
+ if line != "" && !strings.HasSuffix(line, " NOTICE\n") {
+ kept = append(kept, line)
+ }
+ }
+ if err := os.WriteFile(filepath.Join(dir, "SHA256SUMS"), []byte(strings.Join(kept, "")), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ untouched := func(what string) {
+ t.Helper()
+ if _, err := os.Stat(filepath.Join(dir, "NOTICE")); !os.IsNotExist(err) {
+ t.Errorf("after %s the candidate was rebuilt: NOTICE is back", what)
+ }
+ }
+
+ older()
+ if err := fiestaRelease(t, releases, fiesta.Version, true); err != nil {
+ t.Fatalf("fiesta release -candidate refused an untagged candidate: %v", err)
+ }
+ assertIsTheDist(t, dir, d)
+
+ older()
+ stray := filepath.Join(dir, "notes.txt")
+ if err := os.WriteFile(stray, []byte("mine"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ err := fiestaRelease(t, releases, fiesta.Version, true)
+ if err == nil {
+ t.Fatal("fiesta release -candidate replaced a directory holding a file its SHA256SUMS does not list")
+ }
+ if !strings.Contains(err.Error(), "notes.txt") {
+ t.Errorf("the refusal does not name the stray file: %v", err)
+ }
+ untouched("a stray file")
+ if b, err := os.ReadFile(stray); err != nil || string(b) != "mine" {
+ t.Errorf("the refused directory was touched: notes.txt is %q, %v", b, err)
+ }
+ if err := os.Remove(stray); err != nil {
+ t.Fatal(err)
+ }
+
+ link := filepath.Join(dir, "LICENSE")
+ if err := os.Remove(link); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(repo, "README"), link); err != nil {
+ t.Fatal(err)
+ }
+ err = fiestaRelease(t, releases, fiesta.Version, true)
+ if err == nil {
+ t.Fatal("fiesta release -candidate replaced a directory holding a symbolic link")
+ }
+ if !strings.Contains(err.Error(), "LICENSE") {
+ t.Errorf("the refusal does not name the link: %v", err)
+ }
+ untouched("a link")
+ if err := os.Remove(link); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(link, d["LICENSE"], 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ git(t, repo, "tag", tag)
+ err = fiestaRelease(t, releases, fiesta.Version, true)
+ if err == nil {
+ t.Fatalf("fiesta release -candidate replaced %s, which the local tag %s names", dir, tag)
+ }
+ if !strings.Contains(err.Error(), tag) {
+ t.Errorf("the refusal does not name the tag: %v", err)
+ }
+ untouched("a local tag")
+
+ git(t, repo, "push", "-q", "origin", tag)
+ git(t, repo, "tag", "-d", tag)
+ if out := git(t, repo, "tag", "-l", tag); strings.TrimSpace(out) != "" {
+ t.Fatalf("the local tag is still there: %q", out)
+ }
+ err = fiestaRelease(t, releases, fiesta.Version, true)
+ if err == nil {
+ t.Fatalf("fiesta release -candidate replaced %s, which origin's tag %s names", dir, tag)
+ }
+ if !strings.Contains(err.Error(), tag) {
+ t.Errorf("the refusal does not name the tag: %v", err)
+ }
+ untouched("a tag on origin")
+
+ err = fiestaRelease(t, releases, fiesta.Version, false)
+ if err == nil {
+ t.Fatal("fiesta release without -candidate replaced an existing directory")
+ }
+ untouched("a run without -candidate")
+}
+
+// TestSiteWritesWhatBuildReturns: `fiesta site -releases R -pages P
+// -out DIR` writes site.Build's tree into DIR, file for file, and
+// stamps DIR as its own.
+func TestSiteWritesWhatBuildReturns(t *testing.T) {
+ root := t.TempDir()
+ releases := filepath.Join(root, "releases")
+ if err := fiestaRelease(t, releases, fiesta.Version, false); err != nil {
+ t.Fatal(err)
+ }
+ pages := filepath.Join(root, "pages")
+ if err := os.MkdirAll(filepath.Join(pages, "themes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for name, text := range map[string]string{"index.html": "<h1>Fiesta</h1>\n", "themes/index.html": "<h1>Themes</h1>\n"} {
+ if err := os.WriteFile(filepath.Join(pages, filepath.FromSlash(name)), []byte(text), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ out := filepath.Join(root, "out", "site")
+ if err := run([]string{"site", "-releases", releases, "-pages", pages, "-out", out}, io.Discard); err != nil {
+ t.Fatalf("fiesta site: %v", err)
+ }
+ want, err := site.Build(os.DirFS(releases), os.DirFS(pages))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(want) == 0 {
+ t.Fatal("the site is empty")
+ }
+ for name, data := range want {
+ got, err := os.ReadFile(filepath.Join(out, filepath.FromSlash(name)))
+ if err != nil {
+ t.Errorf("the site did not write %s: %v", name, err)
+ continue
+ }
+ if !bytes.Equal(got, data) {
+ t.Errorf("%s on disk differs from the build", name)
+ }
+ }
+ major, _, _ := strings.Cut(fiesta.Version, ".")
+ if got := string(want["_redirects"]); got != "/"+major+"/* /"+fiesta.Version+"/:splat 200\n" {
+ t.Errorf("_redirects is %q", got)
+ }
+ if b, err := os.ReadFile(filepath.Join(out, stampName)); err != nil || string(b) != stampBody("fiesta site") {
+ t.Errorf("the stamp is not fiesta site's: %q, %v", b, err)
+ }
+ var written int
+ err = filepath.WalkDir(out, func(p string, e os.DirEntry, err error) error {
+ if err == nil && !e.IsDir() && filepath.Base(p) != stampName {
+ written++
+ }
+ return err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if written != len(want) {
+ t.Errorf("%s holds %d files besides the stamp, the build has %d", out, written, len(want))
+ }
+}
diff --git a/internal/site/site.go b/internal/site/site.go
new file mode 100644
index 0000000..fc0ad5e
--- /dev/null
+++ b/internal/site/site.go
@@ -0,0 +1,230 @@
+// Package site assembles fiesta.rastrillo.org: the committed releases
+// under their exact versions, a rewrite per major onto its newest
+// release, and the pages at the root.
+//
+// Build is a pure function of the two trees it is handed, so the same
+// releases and pages are the same site on anyone's machine. cmd/fiesta
+// writes what Build returns, and a static host serves it as is.
+package site
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io/fs"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// sumsName is the file every release carries, one `<hex> <path>` line
+// per file, which internal/build writes and Release checks.
+const sumsName = "SHA256SUMS"
+
+// redirectsName is the rules file at the root of a static site, in the
+// format Netlify introduced and CARLOS's edge serves. It is the one
+// file Build writes that is neither a release nor a page.
+const redirectsName = "_redirects"
+
+// strictVersion is X.Y.Z and nothing else: no v, no prerelease, no
+// build metadata, no leading zero. A release directory is named this
+// way or it is not a release.
+var strictVersion = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
+
+// allDigits is a first path segment that reserves a major's namespace:
+// /1/, /2/ and so on, whether or not that major has a release yet.
+var allDigits = regexp.MustCompile(`^[0-9]+$`)
+
+// version is a parsed X.Y.Z, for ordering.
+type version struct {
+ major, minor, patch int
+ name string
+}
+
+// parseVersion reads a strict X.Y.Z, or reports that name is not one.
+func parseVersion(name string) (version, bool) {
+ m := strictVersion.FindStringSubmatch(name)
+ if m == nil {
+ return version{}, false
+ }
+ v := version{name: name}
+ v.major, _ = strconv.Atoi(m[1])
+ v.minor, _ = strconv.Atoi(m[2])
+ v.patch, _ = strconv.Atoi(m[3])
+ return v, true
+}
+
+// less orders versions numerically, so 1.10.0 is newer than 1.9.3.
+func (v version) less(w version) bool {
+ if v.major != w.major {
+ return v.major < w.major
+ }
+ if v.minor != w.minor {
+ return v.minor < w.minor
+ }
+ return v.patch < w.patch
+}
+
+// Build assembles the site from releases, a tree of X.Y.Z directories
+// as fiesta release writes them, and pages, a tree of files for the
+// root. It returns every file keyed by its path on the site:
+//
+// - X.Y.Z/<file> for every file of every release, each checked
+// against the release's SHA256SUMS first (Release);
+// - _redirects, one rule per major, newest major first, sending
+// /M/* to that major's newest release: `/M/* /M.y.z/:splat 200`;
+// - every page, at the path it has under pages.
+//
+// Anything else is an error, and the first error is the whole answer:
+// a release directory not named strictly X.Y.Z, a loose file at the
+// top of releases, a release whose files disagree with its sums, or a
+// page that would shadow a version. CARLOS's edge serves a real file
+// at the request path before any rewrite that is not forced, so a page
+// under an all-digit first segment would answer for /M/*, released or
+// not, and a page under a version-shaped segment would answer for that
+// release. Both are refused, and so is a page named _redirects, which
+// is the rules file.
+func Build(releases fs.FS, pages fs.FS) (map[string][]byte, error) {
+ out := map[string][]byte{}
+ entries, err := fs.ReadDir(releases, ".")
+ if err != nil {
+ return nil, fmt.Errorf("reading releases: %w", err)
+ }
+ var versions []version
+ for _, e := range entries {
+ v, ok := parseVersion(e.Name())
+ if !ok || !e.IsDir() {
+ return nil, fmt.Errorf("releases/%s is not a release: a release is a directory named X.Y.Z, nothing else", e.Name())
+ }
+ dir, err := fs.Sub(releases, v.name)
+ if err != nil {
+ return nil, err
+ }
+ files, err := Release(dir)
+ if err != nil {
+ return nil, fmt.Errorf("releases/%s/%w", v.name, err)
+ }
+ for name, data := range files {
+ out[v.name+"/"+name] = data
+ }
+ versions = append(versions, v)
+ }
+
+ // The newest release of each major, majors newest first.
+ newest := map[int]version{}
+ for _, v := range versions {
+ if cur, ok := newest[v.major]; !ok || cur.less(v) {
+ newest[v.major] = v
+ }
+ }
+ majors := make([]int, 0, len(newest))
+ for m := range newest {
+ majors = append(majors, m)
+ }
+ sort.Sort(sort.Reverse(sort.IntSlice(majors)))
+ var rules bytes.Buffer
+ for _, m := range majors {
+ fmt.Fprintf(&rules, "/%d/* /%s/:splat 200\n", m, newest[m].name)
+ }
+ out[redirectsName] = rules.Bytes()
+
+ err = fs.WalkDir(pages, ".", func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return fmt.Errorf("reading pages: %w", err)
+ }
+ if p == "." || d.IsDir() {
+ return nil
+ }
+ // Checked per file rather than per directory, so the refusal
+ // names a page someone wrote and not the directory above it.
+ first, _, _ := strings.Cut(p, "/")
+ switch {
+ case allDigits.MatchString(first):
+ return fmt.Errorf("page %s would shadow /%s/, the rewrite onto the newest %s.x release, so no page may start with digits alone", p, first, first)
+ case strictVersion.MatchString(first):
+ return fmt.Errorf("page %s would shadow the release directory %s/, so no page may start with a version", p, first)
+ case first == redirectsName:
+ return fmt.Errorf("page %s is the rules file, which the site writes itself", p)
+ }
+ if !d.Type().IsRegular() {
+ return fmt.Errorf("page %s is not a regular file", p)
+ }
+ data, err := fs.ReadFile(pages, p)
+ if err != nil {
+ return fmt.Errorf("reading page %s: %w", p, err)
+ }
+ out[p] = data
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// Release reads one release directory and checks it against its
+// SHA256SUMS: every file is listed and its bytes match, and every
+// listed file is present. It returns the files keyed by path, the sums
+// file included, or the first disagreement. Every error begins with
+// the path of the file at fault, relative to dir, so a caller that
+// knows where dir is can prefix it. A release that fails this is not
+// published, and fiesta release will not replace it as a candidate,
+// because it is not what Fiesta wrote.
+//
+// Only regular files are a release's. A link, or anything else, is
+// refused by name: fiesta release writes none, and reading through one
+// would check bytes that live somewhere else.
+func Release(dir fs.FS) (map[string][]byte, error) {
+ sums, err := fs.ReadFile(dir, sumsName)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", sumsName, err)
+ }
+ listed := map[string]string{}
+ for i, line := range strings.Split(strings.TrimSuffix(string(sums), "\n"), "\n") {
+ hash, name, ok := strings.Cut(line, " ")
+ if !ok || len(hash) != sha256.Size*2 || name == "" {
+ return nil, fmt.Errorf("%s line %d is not `<sha256> <path>`: %q", sumsName, i+1, line)
+ }
+ listed[name] = hash
+ }
+ files := map[string][]byte{sumsName: sums}
+ err = fs.WalkDir(dir, ".", func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if p == "." || d.IsDir() {
+ return nil
+ }
+ if p == sumsName {
+ return nil
+ }
+ if !d.Type().IsRegular() {
+ return fmt.Errorf("%s is not a regular file, so it is not a release file", p)
+ }
+ want, ok := listed[p]
+ if !ok {
+ return fmt.Errorf("%s is not listed in %s", p, sumsName)
+ }
+ data, err := fs.ReadFile(dir, p)
+ if err != nil {
+ return err
+ }
+ sum := sha256.Sum256(data)
+ if hex.EncodeToString(sum[:]) != want {
+ return fmt.Errorf("%s does not match its line in %s", p, sumsName)
+ }
+ files[p] = data
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ for name := range listed {
+ if _, ok := files[name]; !ok {
+ return nil, fmt.Errorf("%s is listed in %s and missing", name, sumsName)
+ }
+ }
+ return files, nil
+}
diff --git a/internal/site/site_test.go b/internal/site/site_test.go
new file mode 100644
index 0000000..af8ab0b
--- /dev/null
+++ b/internal/site/site_test.go
@@ -0,0 +1,281 @@
+package site_test
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "io/fs"
+ "sort"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/internal/build"
+ "amadan.net/rastrillo/fiesta/internal/site"
+)
+
+// sumsOf is SHA256SUMS as build.Dist writes it: one `<hex> <path>`
+// line per file, sorted by path, the sums file itself left out.
+func sumsOf(files map[string][]byte) []byte {
+ names := make([]string, 0, len(files))
+ for name := range files {
+ if name != "SHA256SUMS" {
+ names = append(names, name)
+ }
+ }
+ sort.Strings(names)
+ var b bytes.Buffer
+ for _, name := range names {
+ sum := sha256.Sum256(files[name])
+ b.WriteString(hex.EncodeToString(sum[:]) + " " + name + "\n")
+ }
+ return b.Bytes()
+}
+
+// release is a small synthetic release: a stylesheet that names its
+// version, a licence and a NOTICE, plus their SHA256SUMS.
+func release(version string) map[string][]byte {
+ files := map[string][]byte{
+ "fiesta.css": []byte("/*! Fiesta " + version + " */\nbody{margin:0}\n"),
+ "LICENSE": []byte("MPL-2.0\n"),
+ "NOTICE": []byte("icons: Lucide\n"),
+ "i18n/es.json": []byte(`{"today":"hoy"}` + "\n"),
+ }
+ files["SHA256SUMS"] = sumsOf(files)
+ return files
+}
+
+// releasesOf is a releases tree holding one synthetic release per
+// version named.
+func releasesOf(versions ...string) fstest.MapFS {
+ m := fstest.MapFS{}
+ for _, v := range versions {
+ for name, data := range release(v) {
+ m[v+"/"+name] = &fstest.MapFile{Data: data}
+ }
+ }
+ return m
+}
+
+// pagesOf is a pages tree from path to content.
+func pagesOf(pages map[string]string) fstest.MapFS {
+ m := fstest.MapFS{}
+ for name, text := range pages {
+ m[name] = &fstest.MapFile{Data: []byte(text)}
+ }
+ return m
+}
+
+// TestOneServesTheNewestOneX: with 1.0.0 and 1.1.0 released, /1/
+// rewrites to 1.1.0, both releases are copied byte for byte, sums file
+// included, the pages land at the root, and nothing else is written.
+func TestOneServesTheNewestOneX(t *testing.T) {
+ releases := releasesOf("1.0.0", "1.1.0")
+ pages := pagesOf(map[string]string{
+ "index.html": "<h1>Fiesta</h1>\n",
+ "themes/index.html": "<h1>Themes</h1>\n",
+ })
+ out, err := site.Build(releases, pages)
+ if err != nil {
+ t.Fatalf("Build: %v", err)
+ }
+ const want = "/1/* /1.1.0/:splat 200\n"
+ if got := string(out["_redirects"]); got != want {
+ t.Errorf("_redirects is %q, want %q", got, want)
+ }
+ for name, f := range releases {
+ if got, ok := out[name]; !ok {
+ t.Errorf("%s was not copied", name)
+ } else if !bytes.Equal(got, f.Data) {
+ t.Errorf("%s differs from the release", name)
+ }
+ }
+ for name, f := range pages {
+ if got, ok := out[name]; !ok {
+ t.Errorf("page %s was not copied to the root", name)
+ } else if !bytes.Equal(got, f.Data) {
+ t.Errorf("page %s differs from the input", name)
+ }
+ }
+ if want := len(releases) + len(pages) + 1; len(out) != want {
+ names := make([]string, 0, len(out))
+ for name := range out {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ t.Errorf("the site holds %d files, want %d (the releases, the pages and _redirects):\n%s", len(out), want, strings.Join(names, "\n"))
+ }
+}
+
+// TestNewestIsNumericNotLexical: 1.10.0 is newer than 1.9.3, which a
+// string sort gets wrong.
+func TestNewestIsNumericNotLexical(t *testing.T) {
+ out, err := site.Build(releasesOf("1.2.0", "1.10.0", "1.9.3"), pagesOf(nil))
+ if err != nil {
+ t.Fatalf("Build: %v", err)
+ }
+ const want = "/1/* /1.10.0/:splat 200\n"
+ if got := string(out["_redirects"]); got != want {
+ t.Errorf("_redirects is %q, want %q", got, want)
+ }
+}
+
+// TestTwoDoesNotMoveOne: a 2.0.0 release adds its own rule, newest
+// major first, and leaves /1/ pointing at the newest 1.x. The whole
+// file is compared, so the format is pinned: two spaces between
+// fields, one line per major, nothing else.
+func TestTwoDoesNotMoveOne(t *testing.T) {
+ out, err := site.Build(releasesOf("1.0.0", "1.1.0", "2.0.0"), pagesOf(nil))
+ if err != nil {
+ t.Fatalf("Build: %v", err)
+ }
+ const want = "/2/* /2.0.0/:splat 200\n" +
+ "/1/* /1.1.0/:splat 200\n"
+ if got := string(out["_redirects"]); got != want {
+ t.Errorf("_redirects is:\n%s\nwant:\n%s", got, want)
+ }
+}
+
+// TestAChangedReleaseIsRefused: a release whose files do not agree
+// with its SHA256SUMS is not published. One byte flipped, a file the
+// sums do not list, and a listed file that is gone each fail Build,
+// naming the file.
+func TestAChangedReleaseIsRefused(t *testing.T) {
+ cases := []struct {
+ name string
+ change func(fstest.MapFS)
+ file string
+ }{
+ {"a flipped byte", func(m fstest.MapFS) {
+ b := append([]byte(nil), m["1.0.0/fiesta.css"].Data...)
+ b[len(b)-2] ^= 1
+ m["1.0.0/fiesta.css"] = &fstest.MapFile{Data: b}
+ }, "1.0.0/fiesta.css"},
+ {"an unlisted file", func(m fstest.MapFS) {
+ m["1.0.0/extra.css"] = &fstest.MapFile{Data: []byte("a{}")}
+ }, "1.0.0/extra.css"},
+ {"a listed file that is missing", func(m fstest.MapFS) {
+ delete(m, "1.0.0/NOTICE")
+ }, "1.0.0/NOTICE"},
+ {"no sums at all", func(m fstest.MapFS) {
+ delete(m, "1.0.0/SHA256SUMS")
+ }, "1.0.0/SHA256SUMS"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ releases := releasesOf("1.0.0", "1.1.0")
+ c.change(releases)
+ out, err := site.Build(releases, pagesOf(nil))
+ if err == nil {
+ t.Fatalf("Build published a release with %s", c.name)
+ }
+ if !strings.Contains(err.Error(), c.file) {
+ t.Errorf("the error does not name %s: %v", c.file, err)
+ }
+ if out != nil {
+ t.Errorf("Build returned files alongside the error")
+ }
+ })
+ }
+}
+
+// TestAPrereleaseDirectoryIsRefused: only strict X.Y.Z directories are
+// releases. A prerelease, a v prefix, a short version, a leading zero
+// and a loose file at the top of releases/ are each refused by name.
+func TestAPrereleaseDirectoryIsRefused(t *testing.T) {
+ for _, name := range []string{"1.1.0-rc.1", "v1.0.0", "1.0", "01.0.0", "1.0.0.1"} {
+ t.Run(name, func(t *testing.T) {
+ releases := releasesOf("1.0.0")
+ for file, data := range release("1.1.0") {
+ releases[name+"/"+file] = &fstest.MapFile{Data: data}
+ }
+ _, err := site.Build(releases, pagesOf(nil))
+ if err == nil {
+ t.Fatalf("Build accepted releases/%s/", name)
+ }
+ if !strings.Contains(err.Error(), name) {
+ t.Errorf("the error does not name %s: %v", name, err)
+ }
+ })
+ }
+ t.Run("a file", func(t *testing.T) {
+ releases := releasesOf("1.0.0")
+ releases["README.md"] = &fstest.MapFile{Data: []byte("# releases\n")}
+ _, err := site.Build(releases, pagesOf(nil))
+ if err == nil || !strings.Contains(err.Error(), "README.md") {
+ t.Errorf("Build did not refuse a loose file at the top of releases/ by name: %v", err)
+ }
+ })
+}
+
+// TestPagesCannotShadowAMajor: an edge serves a real file before a
+// rewrite, so a page under an all-digit first segment would shadow a
+// major's rule. Every such segment is reserved, released or not, and
+// a bare all-digit file at the root is too.
+func TestPagesCannotShadowAMajor(t *testing.T) {
+ for _, page := range []string{"1/fiesta.css", "7/x.html", "3"} {
+ t.Run(page, func(t *testing.T) {
+ pages := pagesOf(map[string]string{"index.html": "<h1>Fiesta</h1>\n", page: "shadow"})
+ _, err := site.Build(releasesOf("1.0.0"), pages)
+ if err == nil {
+ t.Fatalf("Build accepted the page %s", page)
+ }
+ if !strings.Contains(err.Error(), page) {
+ t.Errorf("the error does not name %s: %v", page, err)
+ }
+ })
+ }
+}
+
+// TestPagesCannotShadowAVersion: a page under a version directory,
+// released or merely shaped like one, and a page named _redirects are
+// refused by name.
+func TestPagesCannotShadowAVersion(t *testing.T) {
+ for _, page := range []string{"1.0.0/index.html", "9.9.9/fiesta.css", "_redirects"} {
+ t.Run(page, func(t *testing.T) {
+ pages := pagesOf(map[string]string{"index.html": "<h1>Fiesta</h1>\n", page: "shadow"})
+ _, err := site.Build(releasesOf("1.0.0"), pages)
+ if err == nil {
+ t.Fatalf("Build accepted the page %s", page)
+ }
+ if !strings.Contains(err.Error(), page) {
+ t.Errorf("the error does not name %s: %v", page, err)
+ }
+ })
+ }
+}
+
+// TestWhatBuildWritesPassesTheCheck: the release build.Dist makes from
+// the real tree, with its own SHA256SUMS, is what Build publishes.
+// This is the one place the two packages' idea of the sums file meets.
+func TestWhatBuildWritesPassesTheCheck(t *testing.T) {
+ dist, err := build.Dist(fiesta.FS(), fiesta.Version)
+ if err != nil {
+ t.Fatal(err)
+ }
+ releases := fstest.MapFS{}
+ for name, data := range dist {
+ releases[fiesta.Version+"/"+name] = &fstest.MapFile{Data: data}
+ }
+ out, err := site.Build(releases, pagesOf(map[string]string{"index.html": "<h1>Fiesta</h1>\n"}))
+ if err != nil {
+ t.Fatalf("Build refused the real dist: %v", err)
+ }
+ for name, data := range dist {
+ if !bytes.Equal(out[fiesta.Version+"/"+name], data) {
+ t.Errorf("%s/%s was not published as built", fiesta.Version, name)
+ }
+ }
+ one, err := fs.Sub(releases, fiesta.Version)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := site.Release(one)
+ if err != nil {
+ t.Fatalf("Release refused the real dist: %v", err)
+ }
+ if len(got) != len(dist) {
+ t.Errorf("Release read %d files, the dist has %d", len(got), len(dist))
+ }
+}