rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package site
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "io/fs"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "sort"
13 "strings"
14 "time"
15)
16
17// A release is published when its tag vX.Y.Z holds its directory: from
18// then on the bytes under /X.Y.Z/ never change, which is the one thing
19// that lets _headers tell browsers to keep them for a year. Before the
20// tag the directory is a candidate that fiesta release -candidate may
21// rebuild under the same name, and a browser told it never changes
22// would keep the old bytes. TagDifferences is the check that stands
23// between the two: the release tests hold every tagged directory to it
24// on every push, and fiesta site -published refuses a version that
25// fails it, so the site can only promise what a tag holds.
26
27// gitTimeout bounds each git call TagDifferences makes, so a git that
28// hangs is a refusal rather than a build that never finishes.
29const gitTimeout = 30 * time.Second
30
31// TagDifferences compares the release directory dir with what the tag
32// v<version> holds under releases/<version>/ in the git repository
33// repo: the same files, byte for byte, the stamp included, with nothing
34// missing and nothing extra. It returns one line per difference, each
35// naming the file at fault, and none when the two agree. An absent
36// tag, a tag holding no such directory, and a missing dir are
37// differences too. The error is for git failing to answer, which is
38// never taken as agreement.
39func TagDifferences(repo, version, dir string) ([]string, error) {
40 // Checked before it is joined into a path or a ref, so "../x" or
41 // "1.0.0^" can name nothing but itself.
42 if !strictVersion.MatchString(version) {
43 return []string{fmt.Sprintf("%q is not a version X.Y.Z", version)}, nil
44 }
45 tag := "v" + version
46 ref := "refs/tags/" + tag
47 if _, err := gitIn(repo, "rev-parse", "-q", "--verify", ref+"^{commit}"); err != nil {
48 var exit *exec.ExitError
49 if errors.As(err, &exit) && exit.ExitCode() == 1 {
50 return []string{fmt.Sprintf("there is no tag %s, so %s is not published", tag, version)}, nil
51 }
52 return nil, err
53 }
54 // --full-tree, so the paths are the repository's own wherever in
55 // it repo points; -z, so a path is never quoted or split.
56 prefix := "releases/" + version + "/"
57 listing, err := gitIn(repo, "ls-tree", "-r", "-z", "--full-tree", ref, "--", prefix)
58 if err != nil {
59 return nil, err
60 }
61 tagged := map[string]string{} // path under the release to its blob
62 for _, entry := range strings.Split(strings.TrimSuffix(string(listing), "\x00"), "\x00") {
63 if entry == "" {
64 continue
65 }
66 meta, name, ok := strings.Cut(entry, "\t")
67 fields := strings.Fields(meta)
68 if !ok || len(fields) != 3 {
69 return nil, fmt.Errorf("git ls-tree %s answered %q", tag, entry)
70 }
71 tagged[strings.TrimPrefix(name, prefix)] = fields[2]
72 }
73 if len(tagged) == 0 {
74 return []string{fmt.Sprintf("tag %s holds no %s, so it is not a release tag", tag, prefix)}, nil
75 }
76 local, err := regularFiles(dir)
77 switch {
78 case errors.Is(err, fs.ErrNotExist):
79 return []string{fmt.Sprintf("%s is tagged but %s is missing: a published release is never removed", tag, dir)}, nil
80 case err != nil:
81 return nil, err
82 }
83 var diffs []string
84 for _, name := range sortedKeys(tagged) {
85 path, ok := local[name]
86 if !ok {
87 diffs = append(diffs, fmt.Sprintf("%s/%s is in tag %s and missing", dir, name, tag))
88 continue
89 }
90 if path == "" {
91 diffs = append(diffs, fmt.Sprintf("%s/%s is not a regular file, and a release holds nothing else", dir, name))
92 continue
93 }
94 want, err := gitIn(repo, "cat-file", "blob", tagged[name])
95 if err != nil {
96 return nil, err
97 }
98 got, err := os.ReadFile(path)
99 if err != nil {
100 return nil, err
101 }
102 if !bytes.Equal(got, want) {
103 diffs = append(diffs, fmt.Sprintf("%s/%s differs from tag %s: a published release never changes; a fix is the next version", dir, name, tag))
104 }
105 }
106 for _, name := range sortedKeys(local) {
107 if _, ok := tagged[name]; !ok {
108 diffs = append(diffs, fmt.Sprintf("%s/%s is not in tag %s: a published release gains nothing after the tag", dir, name, tag))
109 }
110 }
111 return diffs, nil
112}
113
114// regularFiles lists every file under dir by its slash path relative
115// to dir, mapped to its path on disk, or to "" for anything that is not
116// a regular file, so the caller can name it rather than read through
117// it. A missing dir is fs.ErrNotExist.
118func regularFiles(dir string) (map[string]string, error) {
119 if _, err := os.Stat(dir); err != nil {
120 return nil, err
121 }
122 files := map[string]string{}
123 err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
124 if err != nil || d.IsDir() {
125 return err
126 }
127 rel, err := filepath.Rel(dir, p)
128 if err != nil {
129 return err
130 }
131 files[filepath.ToSlash(rel)] = ""
132 if d.Type().IsRegular() {
133 files[filepath.ToSlash(rel)] = p
134 }
135 return nil
136 })
137 return files, err
138}
139
140// sortedKeys is m's keys in order, so differences come out the same
141// way every run.
142func sortedKeys(m map[string]string) []string {
143 keys := make([]string, 0, len(m))
144 for k := range m {
145 keys = append(keys, k)
146 }
147 sort.Strings(keys)
148 return keys
149}
150
151// gitIn runs git in repo and returns its standard output.
152func gitIn(repo string, args ...string) ([]byte, error) {
153 ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
154 defer cancel()
155 cmd := exec.CommandContext(ctx, "git", append([]string{"-C", repo}, args...)...)
156 var stderr strings.Builder
157 cmd.Stderr = &stderr
158 out, err := cmd.Output()
159 if err != nil {
160 if ctx.Err() != nil {
161 return nil, fmt.Errorf("git %s: no answer within %s", args[0], gitTimeout)
162 }
163 // Wrapped, so a caller can still read the exit code.
164 return nil, fmt.Errorf("git %s: %w: %s", args[0], err, strings.TrimSpace(stderr.String()))
165 }
166 return out, nil
167}
168