| 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. |
| 54 | package main |
| 55 | |
| 56 | import ( |
| 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 | |
| 76 | const 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 | |
| 80 | func 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. |
| 89 | func 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. |
| 110 | func 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. |
| 128 | func 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. |
| 141 | func 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. |
| 155 | func 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. |
| 170 | func 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. |
| 204 | func 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. |
| 214 | func 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. |
| 227 | const 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. |
| 237 | func 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. |
| 295 | func 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. |
| 344 | const 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. |
| 352 | func 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. |
| 384 | func 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. |
| 397 | func 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 | |