package api
import (
"archive/tar"
"bufio"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
// releaseStub stands in for build-linux.sh: a quarry that prints its
// version, a zit, and every runner download the tree's build script
// lists, with their SHA256SUMS, as the real build lays them out.
const releaseStub = `#!/bin/sh
set -eu
arch=$1 prefix=$2
mkdir -p "$prefix/runner"
printf '#!/bin/sh\necho "quarry %s"\n' "$VERSION" >"$prefix/quarry"
printf '#!/bin/sh\necho "zit 0.0.0 linux-%s"\n' "$arch" >"$prefix/zit"
chmod 0755 "$prefix/quarry" "$prefix/zit"
for t in $(sed -n 's/^RUNNER_TARGETS="\(.*\)"$/\1/p' scripts/build-linux.sh); do
os=${t%/*}; a=${t#*/}; ext=; [ "$os" = windows ] && ext=.exe
echo "runner $t $VERSION" >"$prefix/runner/quarry-runner-$os-$a$ext"
done
if command -v sha256sum >/dev/null 2>&1; then s="sha256sum"; else s="shasum -a 256"; fi
(cd "$prefix/runner" && $s quarry-runner-* >SHA256SUMS)
`
// releaseTree is a throwaway quarry-shaped repository at version ver,
// carrying the scripts release.sh runs and tagged version-<ver>; it
// returns the checkout and the tagged check-in.
func releaseTree(t *testing.T, ver string) (string, string) {
t.Helper()
for _, bin := range []string{"zit", "sh", "tar", "gzip"} {
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("%s not on PATH", bin)
}
}
dir := t.TempDir()
zitIn(t, dir, "init")
files := map[string][]byte{
"VERSION": []byte(ver + "\n"),
"CHANGELOG.md": []byte("# Changelog\n\n## " + ver + " — 2026-10-02\n\n- A release.\n\n## 0.1.0\n\n- Before.\n"),
"LICENSE": []byte("license\n"),
}
for _, s := range []string{"release.sh", "version-stamp.sh", "runner-check.sh", "build-linux.sh"} {
raw, err := os.ReadFile(filepath.Join("..", "..", "scripts", s))
if err != nil {
t.Fatal(err)
}
files["scripts/"+s] = raw
}
for name, body := range files {
p := filepath.Join(dir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, body, 0o644); err != nil {
t.Fatal(err)
}
zitIn(t, dir, "add", name)
}
zitIn(t, dir, "commit", "-m", "release "+ver)
checkin := strings.SplitN(zitIn(t, dir, "info", "current"), "\n", 2)[0]
zitIn(t, dir, "tag", "add", "version-"+ver, checkin)
return dir, checkin
}
func zitIn(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("zit", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("zit %v: %v\n%s", args, err, out)
}
return string(out)
}
// runRelease runs release.sh --dry-run in dir with the stub builder.
func runRelease(t *testing.T, dir, out string, args ...string) (string, error) {
t.Helper()
stub := filepath.Join(t.TempDir(), "build-stub.sh")
if err := os.WriteFile(stub, []byte(releaseStub), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("sh", append([]string{"scripts/release.sh", "--dry-run", "--out", out}, args...)...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "RELEASE_BUILD=sh "+stub, "TMPDIR="+t.TempDir(), "QUARRY_TAG=")
b, err := cmd.CombinedOutput()
return string(b), err
}
func fileSum(t *testing.T, p string) (string, int64) {
t.Helper()
raw, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
h := sha256.Sum256(raw)
return hex.EncodeToString(h[:]), int64(len(raw))
}
// RELEASE.SH --DRY-RUN WRITES A RELEASE THE FORGE AND DEPLOY.SH READ
// (RFC 0020 §releases): the manifest parses as the Releases page parses
// it, every sha256 in it is SHA256SUMS' and the bytes', and the tarballs
// are the one-directory layout deploy.sh --release unpacks.
func TestReleaseScriptDryRunWritesWhatTheForgeReads(t *testing.T) {
const ver = "9.8.7"
dir, checkin := releaseTree(t, ver)
out := filepath.Join(t.TempDir(), "out")
if log, err := runRelease(t, dir, out, ver); err != nil {
t.Fatalf("FAIL: release.sh --dry-run: %v\n%s", err, log)
}
// SHA256SUMS: one sum and one bare name per line, as deploy.sh
// insists, and a line for every file but itself and the manifest.
sums := map[string]string{}
f, err := os.Open(filepath.Join(out, "SHA256SUMS"))
if err != nil {
t.Fatal(err)
}
line := regexp.MustCompile(`^([0-9a-f]{64}) [ *]([A-Za-z0-9_][A-Za-z0-9._-]*)$`)
sc := bufio.NewScanner(f)
for sc.Scan() {
m := line.FindStringSubmatch(sc.Text())
if m == nil {
t.Errorf("FAIL: SHA256SUMS line %q is not a sum and a bare name", sc.Text())
continue
}
sums[m[2]] = m[1]
}
f.Close()
entries, err := os.ReadDir(out)
if err != nil {
t.Fatal(err)
}
var names []string
for _, e := range entries {
n := e.Name()
names = append(names, n)
if n == "SHA256SUMS" || n == "release-manifest.json" {
continue
}
got, _ := fileSum(t, filepath.Join(out, n))
if sums[n] != got {
t.Errorf("FAIL: SHA256SUMS says %s is %q; its bytes are %s", n, sums[n], got)
}
}
sort.Strings(names)
want := []string{
"SHA256SUMS",
"quarry-9.8.7-linux-amd64.tar.gz", "quarry-9.8.7-linux-arm64.tar.gz", "quarry-9.8.7-src.tar.gz",
"quarry-runner-darwin-amd64", "quarry-runner-darwin-arm64", "quarry-runner-linux-amd64",
"quarry-runner-linux-arm64", "quarry-runner-windows-amd64.exe",
"release-manifest.json",
}
if strings.Join(names, " ") != strings.Join(want, " ") {
t.Errorf("FAIL: the release holds\n %v\nwant\n %v", names, want)
}
if len(sums) != len(want)-2 {
t.Errorf("FAIL: SHA256SUMS has %d lines; want %d", len(sums), len(want)-2)
}
// The manifest, through the Releases page's own parser.
raw, err := os.ReadFile(filepath.Join(out, "release-manifest.json"))
if err != nil {
t.Fatal(err)
}
m, err := parseReleaseManifest(raw, ver)
if err != nil {
t.Fatalf("FAIL: the forge refuses the manifest: %v\n%s", err, raw)
}
if m.Version != ver || m.Tag != "version-"+ver || m.Checkin != checkin || m.Date == "" {
t.Errorf("FAIL: manifest release = %q %q %q %q; want %s, version-%s, %s and a date", m.Version, m.Tag, m.Checkin, m.Date, ver, ver, checkin)
}
described := append([]releaseArtifact{}, m.Artifacts...)
if m.Source == nil {
t.Error("FAIL: the manifest names no source archive")
} else {
described = append(described, *m.Source)
}
seen := map[string]bool{}
for _, a := range described {
seen[a.Name] = true
got, size := fileSum(t, filepath.Join(out, a.Name))
if a.SHA256 != got || a.SHA256 != sums[a.Name] || a.Size != size {
t.Errorf("FAIL: manifest says %s is %s, %d bytes; its bytes are %s, %d; SHA256SUMS says %s", a.Name, a.SHA256, a.Size, got, size, sums[a.Name])
}
if a.Target == "" || a.Qualification == "" {
t.Errorf("FAIL: %s has target %q and qualification %q; want both", a.Name, a.Target, a.Qualification)
}
}
for n := range sums {
if !seen[n] {
t.Errorf("FAIL: %s is published and the manifest does not describe it", n)
}
}
omitted := map[string]bool{}
for _, o := range m.Omitted {
omitted[o.Target] = o.Reason != ""
}
for _, t2 := range []string{"darwin-arm64", "darwin-amd64", "windows-amd64"} {
if !omitted[t2] {
t.Errorf("FAIL: the manifest does not name %s as omitted, with a reason: %+v", t2, m.Omitted)
}
}
if len(m.Qualification) == 0 {
t.Error("FAIL: the manifest carries no qualification notes")
}
// The layout deploy.sh --release unpacks: one directory, holding
// quarry, zit, CHANGELOG.md and LICENSE, and nothing else.
for _, arch := range []string{"amd64", "arm64"} {
top := "quarry-" + ver + "-linux-" + arch
members := tarMembers(t, filepath.Join(out, top+".tar.gz"))
wantM := []string{top + "/", top + "/CHANGELOG.md", top + "/LICENSE", top + "/quarry", top + "/zit"}
if strings.Join(members, " ") != strings.Join(wantM, " ") {
t.Errorf("FAIL: %s.tar.gz holds %v; want %v", top, members, wantM)
}
}
t.Log("ok: the dry run's manifest parses, its sums are SHA256SUMS' and the bytes', the tarballs are deploy's layout")
}
func tarMembers(t *testing.T, p string) []string {
t.Helper()
f, err := os.Open(p)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
tr := tar.NewReader(gz)
var out []string
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
name := strings.TrimPrefix(h.Name, "./")
if h.Typeflag == tar.TypeDir && !strings.HasSuffix(name, "/") {
name += "/"
}
out = append(out, name)
}
sort.Strings(out)
return out
}
// RELEASE.SH REFUSES WHAT IS NOT RELEASE X.Y.Z: another version than
// VERSION, a check-in that does not carry the tag itself, an untracked
// file, a changed one, a changelog with no section for it, and a run
// started by another tag.
func TestReleaseScriptRefusesWhatIsNotTheRelease(t *testing.T) {
const ver = "9.8.7"
dir, _ := releaseTree(t, ver)
out := filepath.Join(t.TempDir(), "out")
refused := func(why, want string, args ...string) {
t.Helper()
log, err := runRelease(t, dir, out, args...)
if err == nil || !strings.Contains(log, want) {
t.Errorf("FAIL: %s: err %v, output\n%s\nwant a refusal naming %q", why, err, log, want)
}
}
refused("another version", "VERSION holds 9.8.7, not 9.8.8", "9.8.8")
if err := os.WriteFile(filepath.Join(dir, "stray.txt"), []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
refused("an untracked file", "untracked files", ver)
os.Remove(filepath.Join(dir, "stray.txt"))
if err := os.WriteFile(filepath.Join(dir, "LICENSE"), []byte("changed\n"), 0o644); err != nil {
t.Fatal(err)
}
refused("a changed file", "has changes", ver)
zitIn(t, dir, "revert")
if err := os.WriteFile(filepath.Join(dir, "CHANGELOG.md"), []byte("# Changelog\n\n## 9.8.70\n\n## 0.1.0-9.8.7\n"), 0o644); err != nil {
t.Fatal(err)
}
refused("no changelog heading", "no \"## 9.8.7\" heading", ver)
zitIn(t, dir, "revert")
cmd := exec.Command("sh", "scripts/release.sh", "--dry-run", ver)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "QUARRY_TAG=version-9.9.9")
if b, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(b), "started by the tag version-9.9.9") {
t.Errorf("FAIL: a run started by another tag: err %v, output\n%s", err, b)
}
// The next check-in inherits nothing: the tag is its parent's.
if err := os.WriteFile(filepath.Join(dir, "LICENSE"), []byte("next\n"), 0o644); err != nil {
t.Fatal(err)
}
zitIn(t, dir, "commit", "-m", "after the release")
refused("an untagged check-in", "does not carry version-9.8.7", ver)
t.Log("ok: a wrong version, a dirty tree, no changelog section, another tag and an untagged check-in are refused")
}