Files
scriptorium/scripts/release_test.go

358 lines
12 KiB
Go

package scripts_test
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const version = "v0.14.0"
// Git operations use real temporary repositories. Go is the expensive boundary:
// record validation commands rather than recursively running this test suite.
const fakeGo = `#!/bin/sh
set -eu
if [ "$1" = env ]; then
case "$2" in
GOHOSTOS) printf 'linux\n' ;;
GOHOSTARCH) printf 'amd64\n' ;;
*) exit 1 ;;
esac
exit 0
fi
[ "$GONOPROXY" = none ]
printf '%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$*" "$GOWORK" "$GOFLAGS" "$GOPROXY" "$GOSUMDB" "$GOTOOLCHAIN" "$GOOS" "$GOARCH" "$CGO_ENABLED" >> "$RELEASE_TEST_LOG"
if [ "$1" = test ] && [ -n "${RELEASE_TEST_MUTATION:-}" ]; then
sh "$RELEASE_TEST_MUTATION"
fi
case "${RELEASE_TEST_FAIL:-}" in
test) [ "$1" != test ] || exit 1 ;;
arm64) [ "$GOARCH" != arm64 ] || exit 1 ;;
esac
`
// Simulate the transport losing the reply after a real local push, rejecting
// the push, or becoming unavailable while recovery queries the remote.
const gitTransport = `#!/bin/sh
set -eu
if [ "$1" = ls-remote ] && [ -f "$RELEASE_TEST_PUSH_MARKER" ]; then
if [ "$RELEASE_TEST_PUSH" = unknown ] || [ "$RELEASE_TEST_PUSH" = confirmed ]; then
exit 1
fi
fi
if [ "$1" = -c ] && [ "$3" = push ]; then
if [ "$RELEASE_TEST_PUSH" = rejected ]; then exit 1; fi
if [ "$RELEASE_TEST_PUSH" = conflict ]; then
"$RELEASE_TEST_GIT" push "$4" HEAD:refs/tags/v0.14.0
else
"$RELEASE_TEST_GIT" "$@"
fi
touch "$RELEASE_TEST_PUSH_MARKER"
if [ "$RELEASE_TEST_PUSH" = confirmed ]; then exit 0; fi
exit 1
fi
exec "$RELEASE_TEST_GIT" "$@"
`
type fixture struct {
t *testing.T
root string
repo string
remote string
bin string
gitPath string
log string
env []string
candidate string
}
func newFixture(t *testing.T) *fixture {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("release command requires a POSIX shell")
}
gitPath, err := exec.LookPath("git")
if err != nil {
t.Fatal("release tests require git:", err)
}
root := t.TempDir()
f := &fixture{
t: t, root: root, repo: filepath.Join(root, "work"),
remote: filepath.Join(root, "origin.git"), bin: filepath.Join(root, "bin"),
gitPath: gitPath, log: filepath.Join(root, "validation.log"),
}
for _, env := range os.Environ() {
key, _, _ := strings.Cut(env, "=")
if !strings.HasPrefix(key, "GIT_") && !strings.HasPrefix(key, "RELEASE_TEST_") && key != "PATH" {
f.env = append(f.env, env)
}
}
f.env = append(f.env,
"PATH="+f.bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull,
"GIT_ALLOW_PROTOCOL=file", "GIT_TERMINAL_PROMPT=0",
"RELEASE_TEST_LOG="+f.log, "RELEASE_TEST_GIT="+gitPath,
"RELEASE_TEST_PUSH_MARKER="+filepath.Join(root, "pushed"),
)
f.run(root, gitPath, "init", "--bare", "--initial-branch=main", f.remote)
f.run(root, gitPath, "init", "--initial-branch=main", f.repo)
f.git("config", "user.name", "Release Test")
f.git("config", "user.email", "release@example.invalid")
f.git("remote", "add", "origin", f.remote)
f.write(filepath.Join(f.bin, "go"), fakeGo, 0o755)
script, err := os.ReadFile("release.sh")
if err != nil {
t.Fatal(err)
}
f.write(filepath.Join(f.repo, "scripts", "release.sh"), string(script), 0o644)
f.write(filepath.Join(f.repo, "docs", "releases", version+".md"), "# Scriptorium "+version+"\n\nTest release.\n", 0o644)
f.write(filepath.Join(f.repo, ".gitignore"), "ignored-output\n", 0o644)
f.write(filepath.Join(f.repo, "go.mod"), "module release.test/example\n\ngo 1.25.5\n", 0o644)
f.commit()
f.candidate = strings.TrimSpace(f.git("rev-parse", "HEAD"))
return f
}
func (f *fixture) write(path, content string, mode os.FileMode) {
f.t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
f.t.Fatal(err)
}
}
func (f *fixture) run(dir, name string, args ...string) string {
f.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir, cmd.Env = dir, f.env
out, err := cmd.CombinedOutput()
if err != nil {
f.t.Fatalf("%s %v: %v\n%s", name, args, err, out)
}
return string(out)
}
func (f *fixture) git(args ...string) string {
f.t.Helper()
return f.run(f.repo, f.gitPath, args...)
}
func (f *fixture) commit() {
f.t.Helper()
f.git("add", ".")
f.git("commit", "-m", "Prepare test candidate")
f.git("push", "origin", "refs/heads/main:refs/heads/main")
}
func (f *fixture) publish(wantSuccess bool, args ...string) string {
f.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", append([]string{"scripts/release.sh"}, args...)...)
cmd.Dir, cmd.Env = f.repo, f.env
out, err := cmd.CombinedOutput()
if (err == nil) != wantSuccess {
f.t.Fatalf("release success=%v, want %v: %v\n%s", err == nil, wantSuccess, err, out)
}
return string(out)
}
func (f *fixture) noPublishedTag() {
f.t.Helper()
if out := f.git("ls-remote", "--tags", f.remote, "refs/tags/"+version); strings.TrimSpace(out) != "" {
f.t.Fatalf("unexpected published tag: %s", out)
}
}
func TestReleasePublishesOnlyValidatedTag(t *testing.T) {
f := newFixture(t)
f.git("tag", "-a", "unrelated", "-m", "Do not publish this")
f.git("config", "push.followTags", "true")
f.git("config", "remote.origin.mirror", "true")
f.write(filepath.Join(f.repo, "ignored-output"), "local build", 0o644)
out := f.publish(true, version)
if !strings.Contains(out, "Source release "+version+" published") || !strings.Contains(out, "asynchronous, not verified") {
t.Fatalf("missing source/delivery status: %s", out)
}
tag := strings.TrimSpace(f.git("rev-parse", "refs/tags/"+version))
if got := strings.TrimSpace(f.git("cat-file", "-t", tag)); got != "tag" {
t.Fatalf("object type = %s", got)
}
if got := strings.TrimSpace(f.git("rev-parse", tag+"^{commit}")); got != f.candidate {
t.Fatalf("tag commit = %s, want %s", got, f.candidate)
}
note, err := os.ReadFile(filepath.Join(f.repo, "docs", "releases", version+".md"))
if err != nil {
t.Fatal(err)
}
if object := f.git("cat-file", "-p", tag); !strings.HasSuffix(object, "\n\n"+string(note)) {
t.Fatalf("tag message differs from committed note: %s", object)
}
refs := f.git("ls-remote", "--refs", f.remote)
if strings.Count(strings.TrimSpace(refs), "\n") != 1 || !strings.Contains(refs, tag+"\trefs/tags/"+version) || !strings.Contains(refs, f.candidate+"\trefs/heads/main") {
t.Fatalf("unexpected published refs: %s", refs)
}
log, err := os.ReadFile(f.log)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(log)), "\n")
if len(lines) != 3 || lines[0] != "test -race ./...|off|-mod=readonly|off|off|local|linux|amd64|1" {
t.Fatalf("unexpected validation calls: %s", log)
}
for i, arch := range []string{"amd64", "arm64"} {
if !strings.HasPrefix(lines[i+1], "build -trimpath -ldflags -s -w -o ") ||
!strings.Contains(lines[i+1], "scriptorium-"+version+"-linux-"+arch+" ./cmd/scriptorium|") ||
!strings.HasSuffix(lines[i+1], "|off|-mod=readonly|off|off|local|linux|"+arch+"|0") {
t.Fatalf("unexpected build: %s", lines[i+1])
}
}
if got := f.git("status", "--porcelain"); got != "" {
t.Fatalf("release dirtied worktree: %s", got)
}
}
func TestReleaseRejectsInvalidCandidates(t *testing.T) {
tests := []struct {
name string
args []string
edit func(*fixture)
}{
{name: "no version"},
{name: "leading zero", args: []string{"v00.14.0"}},
{name: "prerelease", args: []string{"v0.14.0-rc.1"}},
{name: "multiline version", args: []string{"v0.14.0\ninvalid"}},
{name: "local tag", edit: func(f *fixture) { f.git("tag", version) }},
{name: "remote tag", edit: func(f *fixture) {
f.run(f.root, f.gitPath, "--git-dir="+f.remote, "update-ref", "refs/tags/"+version, f.candidate)
}},
{name: "dirty", edit: func(f *fixture) { f.write(filepath.Join(f.repo, ".gitignore"), "changed\n", 0o644) }},
{name: "untracked", edit: func(f *fixture) { f.write(filepath.Join(f.repo, "untracked"), "new\n", 0o644) }},
{name: "staged", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, ".gitignore"), "changed\n", 0o644)
f.git("add", ".gitignore")
}},
{name: "wrong branch", edit: func(f *fixture) { f.git("switch", "-c", "topic") }},
{name: "unpublished main", edit: func(f *fixture) { f.git("commit", "--allow-empty", "-m", "Unpublished") }},
{name: "missing note", edit: func(f *fixture) { f.git("rm", "docs/releases/"+version+".md"); f.commit() }},
{name: "mismatched title", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, "docs/releases/"+version+".md"), "# Scriptorium v0.14.1\n", 0o644)
f.commit()
}},
{name: "empty note", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, "docs/releases/"+version+".md"), "", 0o644)
f.commit()
}},
{name: "committed workspace", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, "go.work"), "go 1.25.5\nuse .\n", 0o644)
f.commit()
}},
{name: "committed vendor tree", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, "vendor", "modules.txt"), "vendored dependency\n", 0o644)
f.commit()
}},
{name: "module replacement", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, "go.mod"), "module release.test/example\n\ngo 1.25.5\nreplace example.org/dependency => ../dependency\n", 0o644)
f.commit()
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFixture(t)
if tt.edit != nil {
tt.edit(f)
}
args := tt.args
if args == nil && tt.name != "no version" {
args = []string{version}
}
f.publish(false, args...)
if _, err := os.Stat(f.log); !os.IsNotExist(err) {
t.Fatalf("invalid candidate reached expensive validation: %v", err)
}
if tt.name != "remote tag" {
f.noPublishedTag()
}
})
}
}
func TestReleaseStopsAfterFailedValidation(t *testing.T) {
for _, step := range []string{"test", "arm64"} {
t.Run(step, func(t *testing.T) {
f := newFixture(t)
f.env = append(f.env, "RELEASE_TEST_FAIL="+step)
f.publish(false, version)
f.noPublishedTag()
if tags := f.git("tag", "--list", version); tags != "" {
t.Fatalf("failed validation created tag: %s", tags)
}
})
}
}
func TestReleaseRechecksCandidateAndRemote(t *testing.T) {
for name, mutation := range map[string]string{
"candidate changed": "git commit --allow-empty -m changed\ngit push origin main\n",
"worktree changed": "touch unexpected\n",
"remote main changed": "git --git-dir=\"$RELEASE_TEST_REMOTE\" update-ref -d refs/heads/main\n",
"remote tag appeared": "git push origin HEAD:refs/tags/v0.14.0\n",
} {
t.Run(name, func(t *testing.T) {
f := newFixture(t)
path := filepath.Join(f.root, "mutate.sh")
f.write(path, "set -eu\n"+mutation, 0o644)
f.env = append(f.env, "RELEASE_TEST_MUTATION="+path, "RELEASE_TEST_REMOTE="+f.remote)
f.publish(false, version)
if tags := f.git("tag", "--list", version); tags != "" {
t.Fatalf("changed candidate created tag: %s", tags)
}
if name != "remote tag appeared" {
f.noPublishedTag()
}
})
}
}
func TestReleaseHandlesPushOutcomes(t *testing.T) {
for _, mode := range []string{"confirmed", "lost reply", "rejected", "unknown", "conflict"} {
t.Run(mode, func(t *testing.T) {
f := newFixture(t)
f.write(filepath.Join(f.bin, "git"), gitTransport, 0o755)
f.env = append(f.env, "RELEASE_TEST_PUSH="+mode)
out := f.publish(mode == "confirmed" || mode == "lost reply", version)
if mode == "unknown" && !strings.Contains(out, "publication status unknown") {
t.Fatalf("did not report uncertainty: %s", out)
}
if tag := f.git("tag", "--list", version); strings.TrimSpace(tag) != version {
t.Fatalf("local tag was not preserved: %s", tag)
}
if mode == "rejected" {
f.noPublishedTag()
}
if mode == "confirmed" || mode == "lost reply" || mode == "unknown" {
tag := strings.TrimSpace(f.git("rev-parse", "refs/tags/"+version))
if remote := f.git("ls-remote", "--refs", f.remote, "refs/tags/"+version); !strings.HasPrefix(remote, tag+"\t") {
t.Fatalf("published tag mismatch: %s", remote)
}
}
})
}
}
func TestReleaseUsesOriginPushEndpoint(t *testing.T) {
f := newFixture(t)
f.git("remote", "set-url", "--push", "origin", f.remote)
f.git("remote", "set-url", "origin", filepath.Join(f.root, "unavailable-fetch.git"))
f.publish(true, version)
}