// releases.go is RFC 0020 rung 5: a repository's releases, read from its
// unversioned files. A release is a folder, releases/<name>/, whatever
// the repository's tags look like; its manifest, when it publishes one,
// is zit's release-manifest.json, and its notes are the CHANGELOG.md
// section whose heading names it.
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/s3"
"craftdesign.group/quarry/internal/uv"
)
const (
releasesPrefix = "releases/"
// releaseManifestName and releaseSumsName are the two files a
// release folder may describe itself with.
releaseManifestName = "release-manifest.json"
releaseSumsName = "SHA256SUMS"
// releaseManifestMax bounds a manifest read through the forge. zit's
// is a few kilobytes; anything near this is not one. It is checked
// against the catalog's size; the read itself is capped by s3's
// maxForgeObject. The two can disagree only for an object changed
// in the bucket behind the forge's back, and then the read's cap is
// the one that holds.
releaseManifestMax = 1 << 20
)
type releaseListIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Name, when given, answers that one release alone: a folder name,
// never a path.
Name string `json:"name" validate:"max=400"`
}
type releaseSummaryIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
// releaseSummaryOut is the Files page's block: how many releases and
// which is newest, from the catalog alone.
type releaseSummaryOut struct {
Count int `json:"count"`
Latest string `json:"latest,omitempty"`
Mtime int64 `json:"mtime,omitempty"`
}
// checkReleaseName refuses what cannot be a folder under releases/.
func checkReleaseName(name string) error {
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
return fmt.Errorf("%q is not a release name: %w", name, ops.ErrInvalid)
}
return nil
}
type releaseListOut struct {
// Bound and Note are uv.list's: no bucket, no releases.
Bound bool `json:"bound"`
Note string `json:"note,omitempty"`
Releases []release `json:"releases"`
}
// release is one folder under releases/.
type release struct {
Name string `json:"name"`
// Newest marks the first group, the one the order puts on top.
Newest bool `json:"newest"`
// Mtime is the newest file's, which orders groups whose names are
// not versions.
Mtime int64 `json:"mtime"`
Files []uv.File `json:"files"`
// Sums names the group's SHA256SUMS, when it has one.
Sums string `json:"sums,omitempty"`
Manifest *releaseManifest `json:"manifest,omitempty"`
ManifestErr string `json:"manifestErr,omitempty"`
// Notes is the CHANGELOG.md section whose heading names the release.
Notes *releaseNotes `json:"notes,omitempty"`
}
type releaseNotes struct {
Heading string `json:"heading"`
HTML string `json:"html"`
}
// releaseManifest is what the page shows of zit's schema version 1.
type releaseManifest struct {
Version string `json:"version"`
Date string `json:"date,omitempty"`
Checkin string `json:"checkin,omitempty"`
Tag string `json:"tag,omitempty"`
Artifacts []releaseArtifact `json:"artifacts"`
Source *releaseArtifact `json:"source,omitempty"`
Omitted []releaseOmitted `json:"omitted,omitempty"`
Qualification []releaseQualification `json:"qualification,omitempty"`
}
type releaseArtifact struct {
Name string `json:"name"`
Target string `json:"target"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Qualification string `json:"qualification,omitempty"`
// File is the unversioned name the artifact is published under, when
// the group holds it.
File string `json:"file,omitempty"`
}
// releaseOmitted is a target the release was not built for, and why
// when the manifest says.
type releaseOmitted struct {
Target string `json:"target"`
Reason string `json:"reason,omitempty"`
}
type releaseQualification struct {
Target string `json:"target"`
Notes []string `json:"notes"`
}
// manifestArtifact is one entry as zit writes it. zit says bytes where
// the RFC said size and fossil_check_in where it said checkin; both
// spellings are read.
type manifestArtifact struct {
Name string `json:"name"`
Target string `json:"target"`
Bytes *int64 `json:"bytes"`
Size *int64 `json:"size"`
SHA256 string `json:"sha256"`
Qualification json.RawMessage `json:"qualification"`
FossilCheckIn string `json:"fossil_check_in"`
Checkin string `json:"checkin"`
}
type manifestFile struct {
SchemaVersion int `json:"schema_version"`
Artifacts []manifestArtifact `json:"artifacts"`
OmittedTargets []json.RawMessage `json:"omitted_targets"`
Qualification map[string]json.RawMessage `json:"qualification"`
Release struct {
Version string `json:"version"`
Date string `json:"date"`
FossilCheckIn string `json:"fossil_check_in"`
Checkin string `json:"checkin"`
FossilTag string `json:"fossil_tag"`
Tag string `json:"tag"`
} `json:"release"`
Source *manifestArtifact `json:"source"`
}
func (a manifestArtifact) view() releaseArtifact {
out := releaseArtifact{Name: a.Name, Target: a.Target, SHA256: a.SHA256,
Qualification: strings.Join(jsonWords(a.Qualification), "; ")}
switch {
case a.Bytes != nil:
out.Size = *a.Bytes
case a.Size != nil:
out.Size = *a.Size
}
return out
}
// jsonWords reads a manifest value meant for people: a string, a list
// of strings, or anything else as its JSON text.
func jsonWords(raw json.RawMessage) []string {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
return []string{s}
}
var list []string
if json.Unmarshal(raw, &list) == nil {
return list
}
var buf bytes.Buffer
if json.Compact(&buf, raw) != nil {
return nil
}
return []string{buf.String()}
}
// parseReleaseManifest reads zit's schema, tolerating missing keys. A
// check-in that is not a hash is dropped, so the page never builds a
// link from whatever else a manifest says there.
func parseReleaseManifest(raw []byte, folder string) (*releaseManifest, error) {
var m manifestFile
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("release-manifest.json is not JSON: %v", err)
}
if m.SchemaVersion != 1 {
return nil, fmt.Errorf("release-manifest.json is schema_version %d; this forge reads 1", m.SchemaVersion)
}
out := &releaseManifest{Version: m.Release.Version, Date: m.Release.Date, Tag: m.Release.FossilTag}
if out.Version == "" {
out.Version = folder
}
if out.Tag == "" {
out.Tag = m.Release.Tag
}
for _, a := range m.Artifacts {
out.Artifacts = append(out.Artifacts, a.view())
}
checkins := []string{m.Release.FossilCheckIn, m.Release.Checkin}
if m.Source != nil {
s := m.Source.view()
out.Source = &s
checkins = append(checkins, m.Source.FossilCheckIn, m.Source.Checkin)
}
for _, c := range checkins {
if out.Checkin == "" && hexTail.MatchString(c) {
out.Checkin = c
}
}
for _, raw := range m.OmittedTargets {
var o releaseOmitted
if json.Unmarshal(raw, &o) == nil && o.Target != "" {
out.Omitted = append(out.Omitted, releaseOmitted{Target: o.Target, Reason: o.Reason})
continue
}
for _, w := range jsonWords(raw) {
out.Omitted = append(out.Omitted, releaseOmitted{Target: w})
}
}
targets := make([]string, 0, len(m.Qualification))
for t := range m.Qualification {
targets = append(targets, t)
}
sort.Strings(targets)
for _, t := range targets {
out.Qualification = append(out.Qualification, releaseQualification{Target: t, Notes: jsonWords(m.Qualification[t])})
}
return out, nil
}
// withFiles is m with each artifact linked to the file the folder holds
// under its name, when it holds one. group maps the folder's file names
// to their full names. m is shared through the cache and not changed.
func (m *releaseManifest) withFiles(group map[string]string) *releaseManifest {
out := *m
out.Artifacts = make([]releaseArtifact, len(m.Artifacts))
for i, a := range m.Artifacts {
a.File = group[a.Name]
out.Artifacts[i] = a
}
if m.Source != nil {
s := *m.Source
s.File = group[s.Name]
out.Source = &s
}
return &out
}
// manifestCache holds parsed manifests by the file's content hash, so a
// page view reads the bucket only for a manifest it has not seen. The
// key is the content, so an entry cannot go stale; the bound is on
// entries, oldest out first.
var manifestCache = struct {
sync.Mutex
entries map[string]manifestEntry
order []string
}{entries: map[string]manifestEntry{}}
const manifestCacheEntries = 128
type manifestEntry struct {
m *releaseManifest
err string
}
// releaseManifestOf is the parsed manifest f holds, from the cache or
// the bucket; errText says why there is none.
func releaseManifestOf(ctx context.Context, bucket s3.Bucket, repo, folder string, f uv.File) (*releaseManifest, string) {
if f.Size > releaseManifestMax {
return nil, fmt.Sprintf("release-manifest.json is %d bytes, more than this page reads", f.Size)
}
key := f.Hash + "\x00" + folder
manifestCache.Lock()
e, ok := manifestCache.entries[key]
manifestCache.Unlock()
if ok {
return e.m, e.err
}
raw, _, err := bucket.Get(ctx, attachClient(), uv.Key(f.Name), time.Now())
if err != nil {
// Not cached: the bucket may answer next time.
log.Printf("uv: %s: reading %s: %v", repo, f.Name, err)
return nil, "release-manifest.json could not be read from the bucket"
}
switch m, perr := parseReleaseManifest(raw, folder); {
case !uv.Verify(f.Hash, raw):
// Cached under the hash the catalog claims, which these bytes
// will never have: a rebuild that fixes the catalog changes
// the key.
e = manifestEntry{err: "release-manifest.json in the bucket is not the file the catalog names; an admin can rebuild the catalog"}
case perr != nil:
e = manifestEntry{err: perr.Error()}
default:
e = manifestEntry{m: m}
}
manifestCache.Lock()
if _, had := manifestCache.entries[key]; !had {
if len(manifestCache.order) >= manifestCacheEntries {
delete(manifestCache.entries, manifestCache.order[0])
manifestCache.order = manifestCache.order[1:]
}
manifestCache.entries[key] = e
manifestCache.order = append(manifestCache.order, key)
}
manifestCache.Unlock()
return e.m, e.err
}
// releaseVersion matches a folder name that reads as a version: dotted
// numbers after an optional v or version-, then an optional pre-release
// or build suffix.
var releaseVersion = regexp.MustCompile(`^(?:v|version-)?(\d+(?:\.\d+)*)([-+].*)?$`)
// compareVersions orders two names releaseVersion matched: numbers by
// value, a missing number as zero, a pre-release below its release.
func compareVersions(a, b []string) int {
na, nb := strings.Split(a[1], "."), strings.Split(b[1], ".")
for i := 0; i < max(len(na), len(nb)); i++ {
var x, y int
if i < len(na) {
x, _ = strconv.Atoi(na[i])
}
if i < len(nb) {
y, _ = strconv.Atoi(nb[i])
}
if x != y {
return x - y
}
}
pa, pb := strings.HasPrefix(a[2], "-"), strings.HasPrefix(b[2], "-")
switch {
case pa && !pb:
return -1
case pb && !pa:
return 1
}
return strings.Compare(a[2], b[2])
}
// orderReleases puts the newest first: by version when every name is
// one, else by the newest file in each group.
func orderReleases(rs []release) {
versions := make([][]string, len(rs))
allVersions := true
for i, r := range rs {
versions[i] = releaseVersion.FindStringSubmatch(r.Name)
allVersions = allVersions && versions[i] != nil
}
idx := make([]int, len(rs))
for i := range idx {
idx[i] = i
}
sort.SliceStable(idx, func(i, j int) bool {
a, b := idx[i], idx[j]
if allVersions {
if c := compareVersions(versions[a], versions[b]); c != 0 {
return c > 0
}
}
if rs[a].Mtime != rs[b].Mtime {
return rs[a].Mtime > rs[b].Mtime
}
return rs[a].Name > rs[b].Name
})
sorted := make([]release, len(rs))
for i, k := range idx {
sorted[i] = rs[k]
}
copy(rs, sorted)
for i := range rs {
rs[i].Newest = i == 0
}
}
// groupReleases is every live file under releases/<name>/, by folder.
func groupReleases(files []uv.File) []release {
byName := map[string]*release{}
var out []*release
for _, f := range files {
rest, ok := strings.CutPrefix(f.Name, releasesPrefix)
folder, file, nested := strings.Cut(rest, "/")
if !ok || !nested || folder == "" || file == "" || f.Deleted {
continue
}
r := byName[folder]
if r == nil {
r = &release{Name: folder}
byName[folder] = r
out = append(out, r)
}
r.Files = append(r.Files, f)
r.Mtime = max(r.Mtime, f.Mtime)
if file == releaseSumsName {
r.Sums = f.Name
}
}
rs := make([]release, len(out))
for i, r := range out {
sort.Slice(r.Files, func(a, b int) bool { return r.Files[a].Name < r.Files[b].Name })
rs[i] = *r
}
orderReleases(rs)
return rs
}
// changelogSection is the part of a CHANGELOG.md under the "## " heading
// that names release as a word of its own (a leading v allowed), up to
// the next heading of that level. body leaves the heading out.
func changelogSection(md []byte, name string) (heading, body string, ok bool) {
lines := strings.Split(string(md), "\n")
start := -1
for i, line := range lines {
h, isHeading := strings.CutPrefix(line, "## ")
if !isHeading {
continue
}
if start >= 0 {
return heading, strings.TrimSpace(strings.Join(lines[start+1:i], "\n")), true
}
for _, word := range strings.FieldsFunc(h, func(r rune) bool {
return r == ' ' || r == '\t' || strings.ContainsRune("[]()`*_,:;", r)
}) {
if word == name || word == "v"+name || word == "version-"+name {
heading, start = strings.TrimSpace(h), i
break
}
}
}
if start < 0 {
return "", "", false
}
return heading, strings.TrimSpace(strings.Join(lines[start+1:], "\n")), true
}
// repoChangelog is CHANGELOG.md at the tip of the default branch; nil
// when the repository has no store or no such file.
func (d Deps) repoChangelog(ctx context.Context, repo *Repo) []byte {
if repo.Zit == nil {
return nil
}
branch, _ := defaultBranchOf(repo)
if branch == "" {
branch = "trunk"
}
md, err := repo.Zit.Cat(ctx, "CHANGELOG.md", d.resolveCheckin(ctx, repo, branch))
if err != nil {
return nil
}
return md
}
// bucketBound is a BucketStore that can say whether a binding exists
// without unsealing its secret, as oltp's does.
type bucketBound interface {
BucketBound(repo string) (bool, error)
}
// uvBound is uvBinding's answer to "does this repository have
// unversioned files at all", from the database alone: no bucket call,
// and no secret unsealed where the store can say so without one. The
// pages asked on every render (the bar, the Files box) must agree with
// release.list, which has none without a bucket of the repository's own.
func (d Deps) uvBound(repo string) bool {
if d.UV == nil || d.Buckets == nil || d.companionParent(repo) != "" {
return false
}
var bound bool
var err error
if bb, ok := d.Buckets.(bucketBound); ok {
bound, err = bb.BucketBound(repo)
} else {
_, bound, err = d.Buckets.Bucket(repo)
}
if err != nil {
log.Printf("uv: %s: binding: %v", repo, err)
}
return bound
}
// hasReleases is the repository bar's question, asked on every page,
// so it is answered by existence from the catalog and never lists a
// bucket.
func (d Deps) hasReleases(repo string) bool {
if !d.uvBound(repo) {
return false
}
has, err := d.UV.UVHasReleases(repo)
if err != nil {
log.Printf("uv: %s: catalog: %v", repo, err)
}
return has
}
func registerReleases(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[releaseSummaryIn, releaseSummaryOut]{
Name: "release.summary",
Caps: "o",
Doc: "How many releases a repository has and which is newest, ordered as release.list orders them, from the catalog alone: no bucket read, no manifest. Zero when it has none or no bucket.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/releases/summary",
Run: func(ctx context.Context, in releaseSummaryIn) (releaseSummaryOut, error) {
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return releaseSummaryOut{}, err
}
if !d.uvBound(in.Repo) {
return releaseSummaryOut{}, nil
}
folders, err := d.UV.UVReleaseFolders(in.Repo)
if err != nil {
return releaseSummaryOut{}, err
}
if len(folders) == 0 {
return releaseSummaryOut{}, nil
}
rs := make([]release, len(folders))
for i, f := range folders {
rs[i] = release{Name: f.Name, Mtime: f.Mtime}
}
orderReleases(rs)
return releaseSummaryOut{Count: len(rs), Latest: rs[0].Name, Mtime: rs[0].Mtime}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[releaseListIn, releaseListOut]{
Name: "release.list",
Caps: "o",
Doc: "A repository's releases (RFC 0020), or with `name` the one release in that folder: its unversioned files under releases/<name>/, one group per folder, newest first (by version when every folder name is one, else by the newest file), the first marked newest. A group's release-manifest.json is read in zit's schema version 1 (artifacts with target, size and sha256; the source check-in; the date; omitted targets; qualification notes); a group's SHA256SUMS is named; the CHANGELOG.md section whose heading names the release is rendered beside it.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/releases",
Run: func(ctx context.Context, in releaseListIn) (releaseListOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return releaseListOut{}, err
}
if in.Name != "" {
if err := checkReleaseName(in.Name); err != nil {
return releaseListOut{}, err
}
}
bucket, _, bound, err := d.uvReady(ctx, in.Repo)
if err != nil {
return releaseListOut{}, err
}
if !bound {
return releaseListOut{Releases: []release{}, Note: NoUVBucket}, nil
}
files, err := d.UV.UVReleaseFiles(in.Repo)
if err != nil {
return releaseListOut{}, err
}
out := releaseListOut{Bound: true, Releases: groupReleases(files)}
if in.Name != "" {
// Grouped and ordered whole first, so the one asked for
// still says whether it is the newest.
var one []release
for _, rel := range out.Releases {
if rel.Name == in.Name {
one = append(one, rel)
}
}
if one == nil {
return releaseListOut{}, fmt.Errorf("no release %q: %w", in.Name, ops.ErrNotFound)
}
out.Releases = one
}
var changelog []byte
if len(out.Releases) > 0 {
changelog = d.repoChangelog(ctx, repo)
}
for i := range out.Releases {
rel := &out.Releases[i]
group := map[string]string{}
var manifest *uv.File
for k, f := range rel.Files {
base := strings.TrimPrefix(f.Name, releasesPrefix+rel.Name+"/")
group[base] = f.Name
if base == releaseManifestName {
manifest = &rel.Files[k]
}
}
if manifest != nil {
m, errText := releaseManifestOf(ctx, bucket, in.Repo, rel.Name, *manifest)
if m != nil {
rel.Manifest = m.withFiles(group)
}
rel.ManifestErr = errText
}
if heading, body, ok := changelogSection(changelog, rel.Name); ok {
html, rerr := renderBodyWith(nil, "text/x-markdown", []byte(body))
if rerr == nil {
rel.Notes = &releaseNotes{Heading: heading, HTML: html}
}
}
}
return out, nil
},
})
}