rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1// Command fiesta is the design system's command line. Four subcommands.
2//
3// fiesta build -out DIR
4//
5// writes the release under DIR/<version>/, where <version> is
6// fiesta.Version: the one-file themes, the separate stylesheets, the
7// stripped scripts, the strings, the icons, the licences and
8// SHA256SUMS, exactly what internal/build.Dist returns. A site mounts
9// that directory and a page links one file from it.
10//
11// fiesta gallery -out DIR [-mount /path]
12//
13// writes the gallery into DIR itself: every example state in every
14// theme, with its markup, the tokens and the icons, exactly what
15// internal/gallery.Render returns, with every URL in it an absolute
16// path under -mount (the site root when not given). It is a
17// development and test artefact, for looking at; nothing publishes
18// it.
19//
20// fiesta release -version X.Y.Z [-releases DIR] [-candidate]
21//
22// writes the same release build makes into DIR/X.Y.Z/ (DIR is
23// releases/ by default), to be committed, stamp included: the
24// releases directory is what fiesta site publishes, and the stamp is
25// the one file in it the site never copies. The version must be
26// fiesta.Version, the one in the tree. A directory that already
27// exists is refused, since a committed release never changes, unless
28// -candidate is passed and no tag vX.Y.Z exists locally or on origin:
29// an unpublished candidate may be rebuilt until it is tagged. See
30// release.
31//
32// fiesta site -releases DIR -site DIR -out DIR [-published X.Y.Z,... -git REPO]
33//
34// assembles the site from the releases and the site source, exactly
35// what internal/site.Build returns, into DIR/public/: each release
36// under its exact version, _redirects sending /M/* to the newest
37// M.y.z and the design system's former addresses to their pages,
38// _headers caching /M/ and each release for five minutes, and the
39// pages at the root. A release named in -published is cached for a
40// year instead, as immutable, and each must match its tag vX.Y.Z in
41// the git repository REPO, file for file, or nothing is written: only
42// a tag's bytes can never change. make site-publish passes both for
43// the site that ships. The pages are what internal/www assembles
44// from the site source: the landing page, the design system under
45// /design-system/, and the source's static/ files. DIR/public/ is the
46// tree to ship, and the stamp sits above it in DIR, so nothing hidden
47// is ever deployed.
48//
49// Each command owns the directory it writes. It empties it before it
50// writes, so what is there afterwards is the output and only the
51// output, and it will only take a directory that is absent, empty, or
52// marked with its own stamp (.fiesta); anything else it refuses,
53// changing nothing. See claim.
54package main
55
56import (
57 "context"
58 "errors"
59 "flag"
60 "fmt"
61 "io"
62 "os"
63 "os/exec"
64 "path/filepath"
65 "sort"
66 "strings"
67 "time"
68
69 "amadan.net/rastrillo/fiesta"
70 "amadan.net/rastrillo/fiesta/internal/build"
71 "amadan.net/rastrillo/fiesta/internal/gallery"
72 "amadan.net/rastrillo/fiesta/internal/site"
73 "amadan.net/rastrillo/fiesta/internal/www"
74)
75
76const usage = "usage: fiesta build -out DIR | fiesta gallery -out DIR [-mount /path] | " +
77 "fiesta release -version X.Y.Z [-releases DIR] [-candidate] | " +
78 "fiesta site -releases DIR -site DIR -out DIR [-published X.Y.Z,... -git REPO]"
79
80func main() {
81 if err := run(os.Args[1:], os.Stderr); err != nil {
82 fmt.Fprintln(os.Stderr, "fiesta:", err)
83 os.Exit(1)
84 }
85}
86
87// run is main without the process: the arguments after the command
88// name, and where help goes.
89func run(args []string, stderr io.Writer) error {
90 if len(args) == 0 {
91 return errors.New(usage)
92 }
93 switch args[0] {
94 case "build":
95 return buildCmd(args[1:], stderr)
96 case "gallery":
97 return galleryCmd(args[1:], stderr)
98 case "release":
99 return releaseCmd(args[1:], stderr)
100 case "site":
101 return siteCmd(args[1:], stderr)
102 default:
103 return fmt.Errorf("unknown command %q\n%s", args[0], usage)
104 }
105}
106
107// parse runs a subcommand's flag set over args: help goes to stderr
108// and returns false with no error, a bad flag or a stray argument is
109// an error, and true means the flags are set and the command may run.
110func parse(flags *flag.FlagSet, args []string, stderr io.Writer) (bool, error) {
111 flags.SetOutput(io.Discard)
112 if err := flags.Parse(args); err != nil {
113 if errors.Is(err, flag.ErrHelp) {
114 flags.SetOutput(stderr)
115 fmt.Fprintln(stderr, usage)
116 flags.PrintDefaults()
117 return false, nil
118 }
119 return false, err
120 }
121 if flags.NArg() > 0 {
122 return false, fmt.Errorf("unexpected argument %q\n%s", flags.Arg(0), usage)
123 }
124 return true, nil
125}
126
127// buildCmd is the build subcommand's flags, then write.
128func buildCmd(args []string, stderr io.Writer) error {
129 flags := flag.NewFlagSet("build", flag.ContinueOnError)
130 out := flags.String("out", "", "directory to write the release into, under DIR/<version>/")
131 if ok, err := parse(flags, args, stderr); !ok {
132 return err
133 }
134 if *out == "" {
135 return errors.New("-out is required\n" + usage)
136 }
137 return write(*out)
138}
139
140// galleryCmd is the gallery subcommand's flags, then write.
141func galleryCmd(args []string, stderr io.Writer) error {
142 flags := flag.NewFlagSet("gallery", flag.ContinueOnError)
143 out := flags.String("out", "", "directory to write the gallery into")
144 mount := flags.String("mount", "", "URL path the gallery will be served from (default the site root)")
145 if ok, err := parse(flags, args, stderr); !ok {
146 return err
147 }
148 if *out == "" {
149 return errors.New("-out is required\n" + usage)
150 }
151 return writeGallery(*out, *mount)
152}
153
154// releaseCmd is the release subcommand's flags, then release.
155func releaseCmd(args []string, stderr io.Writer) error {
156 flags := flag.NewFlagSet("release", flag.ContinueOnError)
157 version := flags.String("version", "", "the version to release, which must be the tree's own ("+fiesta.Version+")")
158 releases := flags.String("releases", "releases", "the directory the release is written under, as DIR/<version>/")
159 candidate := flags.Bool("candidate", false, "replace an existing, untagged release directory whole")
160 if ok, err := parse(flags, args, stderr); !ok {
161 return err
162 }
163 if *version == "" {
164 return errors.New("-version is required\n" + usage)
165 }
166 return release(*releases, *version, *candidate)
167}
168
169// siteCmd is the site subcommand's flags, then writeSite.
170func siteCmd(args []string, stderr io.Writer) error {
171 flags := flag.NewFlagSet("site", flag.ContinueOnError)
172 releases := flags.String("releases", "", "the directory of committed releases, one X.Y.Z/ each")
173 siteDir := flags.String("site", "", "the site source: its static/ is copied to the site's root")
174 out := flags.String("out", "", "directory to write the site into")
175 published := flags.String("published", "", "comma-separated releases browsers may keep for a year, each matching its tag vX.Y.Z in -git")
176 repo := flags.String("git", "", "the git repository whose tags hold the -published releases")
177 if ok, err := parse(flags, args, stderr); !ok {
178 return err
179 }
180 for _, f := range []struct{ name, value string }{{"-releases", *releases}, {"-site", *siteDir}, {"-out", *out}} {
181 if f.value == "" {
182 return errors.New(f.name + " is required\n" + usage)
183 }
184 }
185 var versions []string
186 if *published != "" {
187 versions = strings.Split(*published, ",")
188 }
189 // Each needs the other: a -git with nothing published is a ship
190 // command missing its list, and a list with no repository has
191 // nothing to be checked against.
192 switch {
193 case len(versions) > 0 && *repo == "":
194 return errors.New("-published needs -git, the repository whose tags hold those releases\n" + usage)
195 case len(versions) == 0 && *repo != "":
196 return errors.New("-git is only for -published, and nothing is published\n" + usage)
197 }
198 return writeSite(*releases, *siteDir, *out, versions, *repo)
199}
200
201// write builds the release and puts it under out/<version>/, a
202// directory it claims first. Separate from the flags so a test can
203// call it with a directory of its own.
204func write(out string) error {
205 dist, err := build.Dist(fiesta.FS(), fiesta.Version)
206 if err != nil {
207 return err
208 }
209 return writeTree(filepath.Join(out, fiesta.Version), "fiesta build", dist)
210}
211
212// writeGallery renders the gallery for mount and puts it in out, a
213// directory it claims first.
214func writeGallery(out, mount string) error {
215 files, err := gallery.Render(fiesta.FS(), gallery.Options{Mount: mount})
216 if err != nil {
217 return err
218 }
219 return writeTree(out, "fiesta gallery", files)
220}
221
222// shipDir is the directory under fiesta site's -out that holds the
223// tree to ship. The stamp claim writes sits in -out itself, above it,
224// so the shipped tree holds nothing hidden: site.Build refuses hidden
225// pages and skips the one hidden file a release carries, and this
226// keeps the command's own mark out too.
227const shipDir = "public"
228
229// writeSite checks each published release against its tag in repo,
230// assembles the pages from the site source, then the site from the
231// releases, those pages and their rules, and puts it in out/public/,
232// after claiming out. A release that is not its tag's is refused
233// before anything is built or written, every difference named: the
234// year's cache is a promise the bytes never change, and only a tag
235// keeps it. A fault in the site source is reported on its own, before
236// the releases are read.
237func writeSite(releases, siteDir, out string, published []string, repo string) error {
238 var differences []string
239 for _, version := range published {
240 diffs, err := site.TagDifferences(repo, version, filepath.Join(releases, version))
241 if err != nil {
242 return fmt.Errorf("refusing to publish %s: could not compare it with its tag: %w", version, err)
243 }
244 differences = append(differences, diffs...)
245 }
246 if len(differences) > 0 {
247 return fmt.Errorf("refusing -published %s: %s", strings.Join(published, ","), strings.Join(differences, "; "))
248 }
249 pages, err := www.Pages(fiesta.FS(), os.DirFS(siteDir))
250 if err != nil {
251 return err
252 }
253 rules := www.Rules(pages)
254 rules.Published = published
255 files, err := site.Build(os.DirFS(releases), www.FS(pages), rules)
256 if err != nil {
257 return err
258 }
259 shipped := make(map[string][]byte, len(files))
260 for name, data := range files {
261 shipped[shipDir+"/"+name] = data
262 }
263 return writeTree(out, "fiesta site", shipped)
264}
265
266// release builds the dist at version and writes it to releases/<version>/.
267//
268// The version must be fiesta.Version: a release is the tree as it is,
269// and the banner in every file says which version that is. The dist is
270// also held to the one-link size limit here, so a release over it is
271// never cut, whatever the gate said.
272//
273// The directory is the committed, published copy, so an existing one
274// is refused: a release that has gone out never changes. The one
275// exception is -candidate, for a release that has been built and
276// committed but not yet tagged, which may be rebuilt after a fix. It
277// is allowed only while both of these hold, and refused by name
278// otherwise:
279//
280// - no tag v<version> exists locally (git rev-parse) or on origin
281// (git ls-remote), so nothing has been published under the name;
282// a git that cannot answer is a refusal, not an absence;
283// - the directory is fiesta release's own, by claim's rule: it holds
284// this command's stamp at the top, no other stamp anywhere below,
285// and no symbolic link. The stamp is committed with the release
286// and is the one file in the directory site.Release does not
287// publish. A directory of files that merely agree with a
288// SHA256SUMS is not enough: that is the sha256sum format, and any
289// checksummed folder has one.
290//
291// An empty directory is taken either way, since there is nothing in it
292// to lose. A candidate that passes is emptied and rewritten, so a file
293// the new dist no longer has is gone too, and the directory is the
294// dist, every time.
295func release(releases, version string, candidate bool) error {
296 if version != fiesta.Version {
297 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)
298 }
299 dist, err := build.Dist(fiesta.FS(), version)
300 if err != nil {
301 return err
302 }
303 if err := build.CheckSize(dist); err != nil {
304 return err
305 }
306 root, err := filepath.Abs(filepath.Join(releases, version))
307 if err != nil {
308 return fmt.Errorf("resolving -releases: %w", err)
309 }
310 // A link is resolved here as claim resolves it, so the refusal
311 // below and the claim see the same directory.
312 if real, err := filepath.EvalSymlinks(root); err == nil {
313 root = real
314 } else if !os.IsNotExist(err) {
315 return fmt.Errorf("resolving %s: %w", root, err)
316 }
317 entries, err := os.ReadDir(root)
318 switch {
319 case os.IsNotExist(err):
320 case err != nil:
321 return fmt.Errorf("reading %s: %w", root, err)
322 case len(entries) > 0:
323 if !candidate {
324 return fmt.Errorf("refusing to replace %s: a committed release never changes. "+
325 "If it is a candidate that has not been tagged, pass -candidate to rebuild it", root)
326 }
327 tag := "v" + version
328 if tagged, err := tagExists(filepath.Dir(filepath.Dir(root)), tag); err != nil {
329 return fmt.Errorf("refusing to replace %s: could not tell whether %s is tagged: %w", root, tag, err)
330 } else if tagged {
331 return fmt.Errorf("refusing to replace %s: the tag %s exists, so it has been released. "+
332 "A fix is the next version", root, tag)
333 }
334 }
335 if err := claim(root, "fiesta release", "-releases"); err != nil {
336 return err
337 }
338 return writeFiles(root, dist)
339}
340
341// gitTimeout bounds each git call tagExists makes. ls-remote talks to
342// origin, and a remote that hangs would otherwise hold the command
343// forever; a run that outlives this is a refusal.
344const gitTimeout = 30 * time.Second
345
346// tagExists reports whether tag is a tag of the repository holding
347// dir, locally or on its origin. Either git failing to answer is an
348// error: the caller is about to empty a directory on the strength of
349// the answer, and "I could not check" is not "no". Neither call may
350// ask for credentials: GIT_TERMINAL_PROMPT=0 makes a remote that
351// wants them fail at once, which is a refusal too.
352func tagExists(dir, tag string) (bool, error) {
353 ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
354 defer cancel()
355 env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
356 local := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "-q", "--verify", "refs/tags/"+tag)
357 local.Env = env
358 local.Stdout, local.Stderr = io.Discard, io.Discard
359 err := local.Run()
360 var exit *exec.ExitError
361 switch {
362 case err == nil:
363 return true, nil
364 case errors.As(err, &exit) && exit.ExitCode() == 1 && ctx.Err() == nil:
365 // rev-parse -q --verify exits 1 for a ref that does not exist.
366 default:
367 return false, fmt.Errorf("git rev-parse: %w", err)
368 }
369 remote := exec.CommandContext(ctx, "git", "-C", dir, "ls-remote", "--tags", "origin", tag)
370 remote.Env = env
371 var stderr strings.Builder
372 remote.Stderr = &stderr
373 out, err := remote.Output()
374 if err != nil {
375 if ctx.Err() != nil {
376 return false, fmt.Errorf("git ls-remote --tags origin: no answer within %s", gitTimeout)
377 }
378 return false, fmt.Errorf("git ls-remote --tags origin: %w: %s", err, strings.TrimSpace(stderr.String()))
379 }
380 return strings.TrimSpace(string(out)) != "", nil
381}
382
383// writeTree claims root for command and writes files under it.
384func writeTree(root, command string, files map[string][]byte) error {
385 root, err := filepath.Abs(root)
386 if err != nil {
387 return fmt.Errorf("resolving -out: %w", err)
388 }
389 if err := claim(root, command, "-out"); err != nil {
390 return err
391 }
392 return writeFiles(root, files)
393}
394
395// writeFiles writes files under root, in name order, creating the
396// directories they need.
397func writeFiles(root string, files map[string][]byte) error {
398 names := make([]string, 0, len(files))
399 for name := range files {
400 names = append(names, name)
401 }
402 sort.Strings(names)
403 for _, name := range names {
404 dest := filepath.Join(root, filepath.FromSlash(name))
405 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
406 return fmt.Errorf("mkdir for %s: %w", name, err)
407 }
408 if err := os.WriteFile(dest, files[name], 0o644); err != nil {
409 return fmt.Errorf("writing %s: %w", name, err)
410 }
411 }
412 return nil
413}
414