| 1 | package site |
| 2 | |
| 3 | import ( |
| 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. |
| 29 | const 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. |
| 39 | func 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. |
| 118 | func 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. |
| 142 | func 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. |
| 152 | func 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 | |