package site import ( "bytes" "context" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "sort" "strings" "time" ) // A release is published when its tag vX.Y.Z holds its directory: from // then on the bytes under /X.Y.Z/ never change, which is the one thing // that lets _headers tell browsers to keep them for a year. Before the // tag the directory is a candidate that fiesta release -candidate may // rebuild under the same name, and a browser told it never changes // would keep the old bytes. TagDifferences is the check that stands // between the two: the release tests hold every tagged directory to it // on every push, and fiesta site -published refuses a version that // fails it, so the site can only promise what a tag holds. // gitTimeout bounds each git call TagDifferences makes, so a git that // hangs is a refusal rather than a build that never finishes. const gitTimeout = 30 * time.Second // TagDifferences compares the release directory dir with what the tag // v holds under releases// in the git repository // repo: the same files, byte for byte, the stamp included, with nothing // missing and nothing extra. It returns one line per difference, each // naming the file at fault, and none when the two agree. An absent // tag, a tag holding no such directory, and a missing dir are // differences too. The error is for git failing to answer, which is // never taken as agreement. func TagDifferences(repo, version, dir string) ([]string, error) { // Checked before it is joined into a path or a ref, so "../x" or // "1.0.0^" can name nothing but itself. if !strictVersion.MatchString(version) { return []string{fmt.Sprintf("%q is not a version X.Y.Z", version)}, nil } tag := "v" + version ref := "refs/tags/" + tag if _, err := gitIn(repo, "rev-parse", "-q", "--verify", ref+"^{commit}"); err != nil { var exit *exec.ExitError if errors.As(err, &exit) && exit.ExitCode() == 1 { return []string{fmt.Sprintf("there is no tag %s, so %s is not published", tag, version)}, nil } return nil, err } // --full-tree, so the paths are the repository's own wherever in // it repo points; -z, so a path is never quoted or split. prefix := "releases/" + version + "/" listing, err := gitIn(repo, "ls-tree", "-r", "-z", "--full-tree", ref, "--", prefix) if err != nil { return nil, err } tagged := map[string]string{} // path under the release to its blob for _, entry := range strings.Split(strings.TrimSuffix(string(listing), "\x00"), "\x00") { if entry == "" { continue } meta, name, ok := strings.Cut(entry, "\t") fields := strings.Fields(meta) if !ok || len(fields) != 3 { return nil, fmt.Errorf("git ls-tree %s answered %q", tag, entry) } tagged[strings.TrimPrefix(name, prefix)] = fields[2] } if len(tagged) == 0 { return []string{fmt.Sprintf("tag %s holds no %s, so it is not a release tag", tag, prefix)}, nil } local, err := regularFiles(dir) switch { case errors.Is(err, fs.ErrNotExist): return []string{fmt.Sprintf("%s is tagged but %s is missing: a published release is never removed", tag, dir)}, nil case err != nil: return nil, err } var diffs []string for _, name := range sortedKeys(tagged) { path, ok := local[name] if !ok { diffs = append(diffs, fmt.Sprintf("%s/%s is in tag %s and missing", dir, name, tag)) continue } if path == "" { diffs = append(diffs, fmt.Sprintf("%s/%s is not a regular file, and a release holds nothing else", dir, name)) continue } want, err := gitIn(repo, "cat-file", "blob", tagged[name]) if err != nil { return nil, err } got, err := os.ReadFile(path) if err != nil { return nil, err } if !bytes.Equal(got, want) { 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)) } } for _, name := range sortedKeys(local) { if _, ok := tagged[name]; !ok { diffs = append(diffs, fmt.Sprintf("%s/%s is not in tag %s: a published release gains nothing after the tag", dir, name, tag)) } } return diffs, nil } // regularFiles lists every file under dir by its slash path relative // to dir, mapped to its path on disk, or to "" for anything that is not // a regular file, so the caller can name it rather than read through // it. A missing dir is fs.ErrNotExist. func regularFiles(dir string) (map[string]string, error) { if _, err := os.Stat(dir); err != nil { return nil, err } files := map[string]string{} err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } rel, err := filepath.Rel(dir, p) if err != nil { return err } files[filepath.ToSlash(rel)] = "" if d.Type().IsRegular() { files[filepath.ToSlash(rel)] = p } return nil }) return files, err } // sortedKeys is m's keys in order, so differences come out the same // way every run. func sortedKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } // gitIn runs git in repo and returns its standard output. func gitIn(repo string, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), gitTimeout) defer cancel() cmd := exec.CommandContext(ctx, "git", append([]string{"-C", repo}, args...)...) var stderr strings.Builder cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { if ctx.Err() != nil { return nil, fmt.Errorf("git %s: no answer within %s", args[0], gitTimeout) } // Wrapped, so a caller can still read the exit code. return nil, fmt.Errorf("git %s: %w: %s", args[0], err, strings.TrimSpace(stderr.String())) } return out, nil }