358 lines
12 KiB
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)
|
|
}
|