Files
audita/scripts/release_test.go

450 lines
16 KiB
Go

package scripts
import (
"encoding/json"
"maps"
"os"
"os/exec"
"path/filepath"
"reflect"
"slices"
"strconv"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
const goStub = `#!/usr/bin/env bash
set -euo pipefail
case ${1:?missing Go command} in
list)
log=$RELEASEKIT_LIST_LOG
printf '%s\0' "$PWD" "GOWORK=$GOWORK" "GOTOOLCHAIN=$GOTOOLCHAIN" "$@" > "$log"
modfile=
for argument in "$@"; do
case $argument in -modfile=*) modfile=${argument#-modfile=} ;; esac
done
[[ -n $modfile && -f $modfile && -f ${modfile%.mod}.sum ]]
cp "$modfile" "$RELEASEKIT_MODULE_LOG"
cp "${modfile%.mod}.sum" "$RELEASEKIT_SUM_LOG"
if [[ ${RELEASEKIT_BOOTSTRAP_STATUS:-0} != 0 ]]; then
exit "$RELEASEKIT_BOOTSTRAP_STATUS"
fi
printf '%s\n' v0.1.0
;;
build)
printf '%s\0' "$PWD" "GOWORK=$GOWORK" "GOTOOLCHAIN=$GOTOOLCHAIN" "$@" > "$RELEASEKIT_BUILD_LOG"
if [[ ${RELEASEKIT_BOOTSTRAP_STATUS:-0} != 0 ]]; then
exit "$RELEASEKIT_BOOTSTRAP_STATUS"
fi
output=
while (($#)); do
case $1 in
-o)
shift
output=${1:?missing output path}
;;
-o=*) output=${1#-o=} ;;
esac
shift
done
[[ -n $output ]]
cat > "$output" <<'RELEASEKIT_TOOL'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\0' "$PWD" "$@" > "$RELEASEKIT_ARGS_LOG"
exit "${RELEASEKIT_TOOL_STATUS:-0}"
RELEASEKIT_TOOL
chmod +x "$output"
;;
*) exit 64 ;;
esac
`
func TestReleaseWrapperForwardsNativeArgumentsAndStatus(t *testing.T) {
for _, toolStatus := range []int{0, 3} {
t.Run("status "+strconv.Itoa(toolStatus), func(t *testing.T) {
layout := newReleaseTestLayout(t)
args := []string{"publish", "v4.2.0", "argument with spaces"}
cmd := exec.Command("bash", append([]string{layout.wrapper}, args...)...)
cmd.Dir = layout.caller
cmd.Env = layout.environment(map[string]string{
"RELEASEKIT_ARGS_LOG": layout.argsLog,
"RELEASEKIT_BUILD_LOG": layout.buildLog,
"RELEASEKIT_LIST_LOG": layout.listLog,
"RELEASEKIT_MODULE_LOG": layout.moduleLog,
"RELEASEKIT_SUM_LOG": layout.sumLog,
"RELEASEKIT_TOOL_STATUS": strconv.Itoa(toolStatus),
})
output, err := cmd.CombinedOutput()
if got := commandStatus(err); got != toolStatus {
t.Fatalf("exit=%d, want %d; output=%q, error=%v", got, toolStatus, output, err)
}
if len(output) != 0 {
t.Fatalf("unexpected output: %q", output)
}
listInvocation := readNULArguments(t, layout.listLog)
buildInvocation := readNULArguments(t, layout.buildLog)
for name, invocation := range map[string][]string{"list": listInvocation, "build": buildInvocation} {
if len(invocation) < 4 || invocation[0] != layout.repo {
t.Fatalf("%s invocation=%q, want repository %q", name, invocation, layout.repo)
}
if invocation[1] != "GOWORK=off" || invocation[2] != "GOTOOLCHAIN=local" {
t.Fatalf("%s environment=%q", name, invocation[1:3])
}
}
listArgs := listInvocation[3:]
buildArgs := buildInvocation[3:]
modfile := argumentValue(listArgs, "-modfile=")
if listArgs[0] != "list" || !slices.Contains(listArgs, "-mod=mod") || !slices.Contains(listArgs, "-m") || !slices.Contains(listArgs, "gitea.maximumdirect.net/eric/ReleaseKit") {
t.Fatalf("unexpected module-selection arguments: %q", listArgs)
}
if modfile == "" || argumentValue(buildArgs, "-modfile=") != modfile || !slices.Contains(buildArgs, "-mod=mod") {
t.Fatalf("list/build did not share alternate module metadata: list=%q build=%q", listArgs, buildArgs)
}
if buildArgs[0] != "build" || !slices.Contains(buildArgs, "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit") {
t.Fatalf("unexpected build arguments: %q", buildArgs)
}
if relative, err := filepath.Rel(layout.repo, modfile); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") {
t.Fatalf("alternate module file %q is not outside repository %q: relative=%q error=%v", modfile, layout.repo, relative, err)
}
if module := readText(t, layout.moduleLog); !strings.Contains(module, "gitea.maximumdirect.net/eric/ReleaseKit v0.1.0") {
t.Fatalf("temporary module file does not retain selected ReleaseKit version: %q", module)
}
if sum := readText(t, layout.sumLog); !strings.Contains(sum, "fixture checksum") {
t.Fatalf("temporary sum file did not copy source checksums: %q", sum)
}
if sourceModule := readText(t, filepath.Join(layout.repo, "go.mod")); !strings.Contains(sourceModule, "gitea.maximumdirect.net/eric/ReleaseKit v0.1.0") {
t.Fatalf("source go.mod changed: %q", sourceModule)
}
if sourceSum := readText(t, filepath.Join(layout.repo, "go.sum")); sourceSum != "fixture checksum\n" {
t.Fatalf("source go.sum changed: %q", sourceSum)
}
if _, err := os.Stat(modfile); !os.IsNotExist(err) {
t.Fatalf("temporary module file remains after wrapper: %v", err)
}
if _, err := os.Stat(filepath.Join(filepath.Dir(modfile), "releasekit.sum")); !os.IsNotExist(err) {
t.Fatalf("temporary sum file remains after wrapper: %v", err)
}
if info, err := os.Stat(filepath.Join(layout.repo, "bin", "releasekit")); err != nil || !info.Mode().IsRegular() {
t.Fatalf("ReleaseKit executable was not built in bin: %v", err)
}
toolInvocation := readNULArguments(t, layout.argsLog)
wantToolInvocation := append([]string{layout.repo}, args...)
if !reflect.DeepEqual(toolInvocation, wantToolInvocation) {
t.Fatalf("tool invocation=%q, want %q", toolInvocation, wantToolInvocation)
}
})
}
}
func TestDeliveryWorkflowUsesModuleSelectedReleaseKit(t *testing.T) {
data, err := os.ReadFile("../.woodpecker/release.yml")
if err != nil {
t.Fatal(err)
}
var workflow struct {
Matrix struct {
Include []map[string]string `yaml:"include"`
} `yaml:"matrix"`
When []struct {
Event string `yaml:"event"`
Ref string `yaml:"ref"`
} `yaml:"when"`
Steps []struct {
Name string `yaml:"name"`
Image string `yaml:"image"`
Environment map[string]struct {
FromSecret string `yaml:"from_secret"`
} `yaml:"environment"`
Commands []string `yaml:"commands"`
} `yaml:"steps"`
}
if err := yaml.Unmarshal(data, &workflow); err != nil {
t.Fatal(err)
}
if len(workflow.Matrix.Include) != 1 || workflow.Matrix.Include[0]["GO_IMAGE"] != "golang:1.25.5" {
t.Fatalf("workflow Go image = %v, want existing Go 1.25.5 image", workflow.Matrix.Include)
}
if len(workflow.When) != 1 || workflow.When[0].Event != "tag" || workflow.When[0].Ref != "refs/tags/v*" {
t.Fatalf("workflow trigger = %+v", workflow.When)
}
if len(workflow.Steps) != 1 || workflow.Steps[0].Name != "deliver" || workflow.Steps[0].Image != "${GO_IMAGE}" {
t.Fatalf("workflow delivery step = %+v", workflow.Steps)
}
if secret := workflow.Steps[0].Environment["RELEASEKIT_GITEA_TOKEN"].FromSecret; secret != "GITEA_RELEASE_TOKEN" {
t.Fatalf("delivery secret = %q, want existing GITEA_RELEASE_TOKEN", secret)
}
if len(workflow.Steps[0].Commands) != 1 {
t.Fatalf("workflow commands = %q, want one bootstrap/delivery command", workflow.Steps[0].Commands)
}
command := workflow.Steps[0].Commands[0]
for _, fragment := range []string{
"git fetch origin \"refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}\"",
"git checkout --detach \"$${tag_commit}\"",
"git status --porcelain --untracked-files=all",
"mktemp -d \"$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX\"",
"trap 'rm -rf \"$${bootstrap_dir}\"' EXIT",
"cp go.mod \"$${bootstrap_dir}/go.mod\"",
"cp go.sum \"$${bootstrap_dir}/go.sum\"",
"export GOWORK=off",
"export GOTOOLCHAIN=local",
"releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit",
"go list -mod=mod -modfile=",
"-m \"$${releasekit_module}\"",
"go build -mod=mod -modfile=",
"-o \"$${bootstrap_dir}/releasekit\" \"$${releasekit_module}/cmd/releasekit\"",
"\"$${bootstrap_dir}/releasekit\" ci-deliver",
"--repo \"$${CI_WORKSPACE}\"",
"--commit \"$${CI_COMMIT_SHA}\"",
"\"$${CI_COMMIT_TAG}\"",
} {
if !strings.Contains(command, fragment) {
t.Fatalf("workflow command is missing %q: %s", fragment, command)
}
}
if strings.Index(command, "git checkout --detach") > strings.Index(command, "go list -mod=mod") || strings.Index(command, "git status --porcelain") > strings.Index(command, "go list -mod=mod") {
t.Fatalf("workflow resolves the tool before verifying the tagged source: %s", command)
}
if strings.Contains(command, "RELEASEKIT_VERSION") || strings.Contains(command, "cmd/releasekit@") {
t.Fatalf("workflow retains a separate tool version: %s", command)
}
goMod, err := os.ReadFile("../go.mod")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(goMod), "gitea.maximumdirect.net/eric/ReleaseKit v0.1.0") {
t.Fatalf("go.mod does not retain selected supported ReleaseKit version: %s", goMod)
}
}
func TestReleaseWrapperStopsWhenBootstrapFails(t *testing.T) {
layout := newReleaseTestLayout(t)
cmd := exec.Command("bash", layout.wrapper, "publish", "v4.2.0")
cmd.Dir = layout.caller
cmd.Env = layout.environment(map[string]string{
"RELEASEKIT_ARGS_LOG": layout.argsLog,
"RELEASEKIT_BUILD_LOG": layout.buildLog,
"RELEASEKIT_LIST_LOG": layout.listLog,
"RELEASEKIT_MODULE_LOG": layout.moduleLog,
"RELEASEKIT_SUM_LOG": layout.sumLog,
"RELEASEKIT_BOOTSTRAP_STATUS": "42",
})
output, err := cmd.CombinedOutput()
if got := commandStatus(err); got != 42 {
t.Fatalf("exit=%d, want 42; output=%q, error=%v", got, output, err)
}
if _, err := os.Stat(layout.argsLog); !os.IsNotExist(err) {
t.Fatalf("ReleaseKit executed after failed bootstrap; stat error=%v", err)
}
if _, err := os.Stat(layout.buildLog); !os.IsNotExist(err) {
t.Fatalf("ReleaseKit build ran after module selection failed; stat error=%v", err)
}
listInvocation := readNULArguments(t, layout.listLog)
modfile := argumentValue(listInvocation[3:], "-modfile=")
if modfile == "" {
t.Fatalf("module selection did not use temporary metadata: %q", listInvocation)
}
if _, err := os.Stat(filepath.Dir(modfile)); !os.IsNotExist(err) {
t.Fatalf("temporary bootstrap directory remains after failed selection: %v", err)
}
}
func TestReleaseKitProfileDeclaresProductAndDeliverySettings(t *testing.T) {
data, err := os.ReadFile("../releasekit.json")
if err != nil {
t.Fatal(err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatal(err)
}
var deliveryFields map[string]json.RawMessage
if err := json.Unmarshal(raw["delivery"], &deliveryFields); err != nil {
t.Fatal(err)
}
if _, exists := deliveryFields["token"]; exists {
t.Fatal("profile must not contain a credential value")
}
var profile struct {
Product string `json:"product"`
Remote string `json:"remote"`
Checks []json.RawMessage `json:"checks"`
Build struct {
MainPackage string `json:"main_package"`
BinaryName string `json:"binary_name"`
Targets []string `json:"targets"`
} `json:"build"`
Delivery struct {
BaseURL string `json:"base_url"`
Owner string `json:"owner"`
Repository string `json:"repository"`
TokenEnv string `json:"token_env"`
} `json:"delivery"`
}
if err := json.Unmarshal(data, &profile); err != nil {
t.Fatal(err)
}
if len(profile.Checks) != 0 {
t.Fatal("profile must not define additional checks")
}
if profile.Product != "Audita" || profile.Remote != "origin" {
t.Fatalf("product=%q remote=%q", profile.Product, profile.Remote)
}
if profile.Build.MainPackage != "./cmd/audita" || profile.Build.BinaryName != "audita" {
t.Fatalf("build=%+v", profile.Build)
}
if !sameMembers(profile.Build.Targets, []string{"linux/amd64", "linux/arm64"}) {
t.Fatalf("build targets=%q", profile.Build.Targets)
}
if profile.Delivery.BaseURL != "https://gitea.maximumdirect.net" || profile.Delivery.Owner != "eric" || profile.Delivery.Repository != "audita" || profile.Delivery.TokenEnv != "RELEASEKIT_GITEA_TOKEN" {
t.Fatalf("delivery=%+v", profile.Delivery)
}
}
func TestReleaseKitTargetMembership(t *testing.T) {
want := []string{"linux/amd64", "linux/arm64"}
for _, test := range []struct {
name string
targets []string
match bool
}{
{"missing amd64", []string{"linux/arm64", "linux/arm64"}, false},
{"missing arm64", []string{"linux/amd64", "linux/amd64"}, false},
{"additional target", []string{"linux/amd64", "linux/arm64", "darwin/arm64"}, false},
{"reordered", []string{"linux/arm64", "linux/amd64"}, true},
{"repeated", []string{"linux/amd64", "linux/arm64", "linux/amd64"}, true},
} {
t.Run(test.name, func(t *testing.T) {
if got := sameMembers(test.targets, want); got != test.match {
t.Fatalf("target membership for %q = %t, want %t", test.targets, got, test.match)
}
})
}
}
type releaseTestLayout struct {
repo string
caller string
wrapper string
argsLog string
buildLog string
listLog string
moduleLog string
sumLog string
path string
}
func newReleaseTestLayout(t *testing.T) releaseTestLayout {
t.Helper()
root := t.TempDir()
layout := releaseTestLayout{
repo: filepath.Join(root, "repository"),
caller: filepath.Join(root, "caller"),
argsLog: filepath.Join(root, "tool-arguments"),
buildLog: filepath.Join(root, "build-arguments"),
listLog: filepath.Join(root, "list-arguments"),
moduleLog: filepath.Join(root, "temporary-go.mod"),
sumLog: filepath.Join(root, "temporary-go.sum"),
path: filepath.Join(root, "bin"),
}
if err := os.MkdirAll(filepath.Join(layout.repo, "scripts"), 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(layout.caller, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(layout.path, 0755); err != nil {
t.Fatal(err)
}
wrapper, err := os.ReadFile("release.sh")
if err != nil {
t.Fatal(err)
}
layout.wrapper = filepath.Join(layout.repo, "scripts", "release.sh")
writeReleaseTestFile(t, layout.wrapper, wrapper, 0755)
writeReleaseTestFile(t, filepath.Join(layout.path, "go"), []byte(goStub), 0755)
writeReleaseTestFile(t, filepath.Join(layout.repo, "go.mod"), []byte("module example.com/audita-fixture\n\ngo 1.25.5\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.1.0\n"), 0644)
writeReleaseTestFile(t, filepath.Join(layout.repo, "go.sum"), []byte("fixture checksum\n"), 0644)
return layout
}
func readText(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func argumentValue(arguments []string, prefix string) string {
for _, argument := range arguments {
if strings.HasPrefix(argument, prefix) {
return strings.TrimPrefix(argument, prefix)
}
}
return ""
}
func (layout releaseTestLayout) environment(values map[string]string) []string {
filtered := make([]string, 0, len(os.Environ())+len(values)+1)
for _, value := range os.Environ() {
key, _, _ := strings.Cut(value, "=")
if _, overridden := values[key]; key == "PATH" || overridden {
continue
}
filtered = append(filtered, value)
}
filtered = append(filtered, "PATH="+layout.path+string(os.PathListSeparator)+os.Getenv("PATH"))
for key, value := range values {
filtered = append(filtered, key+"="+value)
}
return filtered
}
func writeReleaseTestFile(t *testing.T, path string, data []byte, mode os.FileMode) {
t.Helper()
if err := os.WriteFile(path, data, mode); err != nil {
t.Fatal(err)
}
}
func readNULArguments(t *testing.T, path string) []string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(string(data), "\x00")
if len(parts) > 0 && parts[len(parts)-1] == "" {
parts = parts[:len(parts)-1]
}
return parts
}
func commandStatus(err error) int {
if err == nil {
return 0
}
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
return -1
}
func sameMembers(got, want []string) bool {
gotSet := make(map[string]struct{}, len(got))
for _, value := range got {
gotSet[value] = struct{}{}
}
wantSet := make(map[string]struct{}, len(want))
for _, value := range want {
wantSet[value] = struct{}{}
}
return maps.Equal(gotSet, wantSet)
}