Update toolchain and release integration
This commit is contained in:
@@ -3,4 +3,8 @@ set -euo pipefail
|
||||
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
cd "$script_dir/.."
|
||||
GOWORK=off GOTOOLCHAIN=local exec go tool releasekit "$@"
|
||||
export GOWORK=off GOTOOLCHAIN=local
|
||||
releasekit_host_os=$(go env GOHOSTOS)
|
||||
releasekit_host_arch=$(go env GOHOSTARCH)
|
||||
export GOOS="$releasekit_host_os" GOARCH="$releasekit_host_arch"
|
||||
exec go tool go.maximumdirect.net/releasekit/cmd/releasekit "$@"
|
||||
|
||||
@@ -2,268 +2,11 @@ package scripts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"go/version"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const goStub = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\0' "$PWD" "GOWORK=$GOWORK" "GOTOOLCHAIN=$GOTOOLCHAIN" "$@" > "$RELEASEKIT_GO_LOG"
|
||||
if [[ ${RELEASEKIT_BOOTSTRAP_STATUS:-0} != 0 ]]; then
|
||||
exit "$RELEASEKIT_BOOTSTRAP_STATUS"
|
||||
fi
|
||||
[[ $1 == tool && $2 == releasekit ]]
|
||||
shift 2
|
||||
printf '%s\0' "$PWD" "$@" > "$RELEASEKIT_ARGS_LOG"
|
||||
exit "${RELEASEKIT_TOOL_STATUS:-0}"
|
||||
`
|
||||
|
||||
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_GO_LOG": layout.goLog,
|
||||
"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)
|
||||
}
|
||||
wantGo := append([]string{layout.repo, "GOWORK=off", "GOTOOLCHAIN=local", "tool", "releasekit"}, args...)
|
||||
if got := readNULArguments(t, layout.goLog); !reflect.DeepEqual(got, wantGo) {
|
||||
t.Fatalf("Go invocation=%q, want %q", got, wantGo)
|
||||
}
|
||||
wantTool := append([]string{layout.repo}, args...)
|
||||
if got := readNULArguments(t, layout.argsLog); !reflect.DeepEqual(got, wantTool) {
|
||||
t.Fatalf("tool invocation=%q, want %q", got, wantTool)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
goMod, err := modfile.Parse("go.mod", []byte(readText(t, "../go.mod")), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if goMod.Go == nil {
|
||||
t.Fatal("module does not declare its Go minimum")
|
||||
}
|
||||
if len(workflow.Matrix.Include) != 1 {
|
||||
t.Fatalf("workflow Go matrix = %v", workflow.Matrix.Include)
|
||||
}
|
||||
image := workflow.Matrix.Include[0]["GO_IMAGE"]
|
||||
_, tag, _ := strings.Cut(image, ":")
|
||||
patch, _, _ := strings.Cut(tag, "-")
|
||||
imageVersion := "go" + patch
|
||||
if !version.IsValid(imageVersion) || version.Compare(imageVersion, "go"+goMod.Go.Version) < 0 {
|
||||
t.Fatalf("workflow Go image %q cannot satisfy module minimum %s", image, goMod.Go.Version)
|
||||
}
|
||||
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}",
|
||||
"git checkout --detach", "git status --porcelain",
|
||||
"go list", "-modfile=", "-m", "go build", "$${releasekit_module}/cmd/releasekit",
|
||||
"ci-deliver", "--repo \"$${CI_WORKSPACE}\"", "--prepared", "--commit",
|
||||
"\"$${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") || strings.Index(command, "git status --porcelain") > strings.Index(command, "go list") {
|
||||
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)
|
||||
}
|
||||
toolSelected := false
|
||||
for _, tool := range goMod.Tool {
|
||||
if tool.Path == "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit" {
|
||||
toolSelected = true
|
||||
}
|
||||
}
|
||||
if !toolSelected {
|
||||
t.Fatal("module does not register the ReleaseKit CLI tool")
|
||||
}
|
||||
}
|
||||
|
||||
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_GO_LOG": layout.goLog,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryWorkflowAcceptsTagObjectsAndCommits(t *testing.T) {
|
||||
var workflow struct {
|
||||
Steps []struct {
|
||||
Commands []string `yaml:"commands"`
|
||||
} `yaml:"steps"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(readText(t, "../.woodpecker/release.yml")), &workflow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := strings.ReplaceAll(workflow.Steps[0].Commands[0], "$${", "${")
|
||||
root := t.TempDir()
|
||||
source := filepath.Join(root, "source")
|
||||
layout := releaseTestLayout{
|
||||
repo: filepath.Join(root, "checkout"), path: filepath.Join(root, "bin"),
|
||||
argsLog: filepath.Join(root, "tool-arguments"),
|
||||
}
|
||||
for _, directory := range []string{source, layout.path} {
|
||||
if err := os.MkdirAll(directory, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
git := func(dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %q: %v\n%s", args, err, output)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
git(source, "init", "-b", "main")
|
||||
git(source, "config", "user.name", "Release test")
|
||||
git(source, "config", "user.email", "release-test@example.invalid")
|
||||
writeReleaseTestFile(t, filepath.Join(source, "go.mod"), []byte(readText(t, "../go.mod")), 0644)
|
||||
writeReleaseTestFile(t, filepath.Join(source, "go.sum"), nil, 0644)
|
||||
git(source, "add", "go.mod", "go.sum")
|
||||
git(source, "commit", "-m", "Initial fixture")
|
||||
unrelated := git(source, "rev-parse", "HEAD")
|
||||
git(source, "commit", "--allow-empty", "-m", "Release fixture")
|
||||
commit := git(source, "rev-parse", "HEAD")
|
||||
git(source, "tag", "-a", "v3.1.0", "-m", "Annotated release")
|
||||
git(source, "tag", "v3.1.1")
|
||||
tagObject := git(source, "rev-parse", "v3.1.0")
|
||||
git(root, "clone", "--quiet", source, layout.repo)
|
||||
toolStub := filepath.Join(root, "releasekit-stub")
|
||||
writeReleaseTestFile(t, toolStub, []byte(`#!/bin/sh
|
||||
printf '%s\0' "$@" > "$RELEASEKIT_ARGS_LOG"
|
||||
`), 0755)
|
||||
writeReleaseTestFile(t, filepath.Join(layout.path, "go"), []byte(`#!/bin/sh
|
||||
set -eu
|
||||
case "$1" in
|
||||
list) exit 0 ;;
|
||||
build)
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "-o" ]; then
|
||||
cp "$RELEASEKIT_STUB" "$2"
|
||||
exit 0
|
||||
fi
|
||||
shift
|
||||
done ;;
|
||||
esac
|
||||
exit 1
|
||||
`), 0755)
|
||||
for _, tc := range []struct {
|
||||
name, tag, eventID string
|
||||
success bool
|
||||
}{
|
||||
{"annotated tag object", "v3.1.0", tagObject, true},
|
||||
{"annotated tag commit", "v3.1.0", commit, true},
|
||||
{"lightweight tag commit", "v3.1.1", commit, true},
|
||||
{"unrelated commit", "v3.1.0", unrelated, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := os.Remove(layout.argsLog); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("sh", "-c", command)
|
||||
cmd.Dir = layout.repo
|
||||
cmd.Env = layout.environment(map[string]string{
|
||||
"CI_WORKSPACE": layout.repo, "CI_COMMIT_TAG": tc.tag, "CI_COMMIT_SHA": tc.eventID,
|
||||
"RELEASEKIT_ARGS_LOG": layout.argsLog, "RELEASEKIT_STUB": toolStub,
|
||||
})
|
||||
output, err := cmd.CombinedOutput()
|
||||
if tc.success {
|
||||
if err != nil {
|
||||
t.Fatalf("workflow failed: %v\n%s", err, output)
|
||||
}
|
||||
want := []string{"ci-deliver", "--repo", layout.repo, "--prepared", "--commit", commit, tc.tag}
|
||||
if got := readNULArguments(t, layout.argsLog); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("delivery arguments = %q, want %q", got, want)
|
||||
}
|
||||
} else {
|
||||
if err == nil {
|
||||
t.Fatal("workflow accepted an unrelated commit")
|
||||
}
|
||||
if _, err := os.Stat(layout.argsLog); !os.IsNotExist(err) {
|
||||
t.Fatalf("delivery ran after mismatched event; stat error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseKitProfileDeclaresProductAndDeliverySettings(t *testing.T) {
|
||||
data, err := os.ReadFile("../releasekit.json")
|
||||
if err != nil {
|
||||
@@ -294,6 +37,7 @@ func TestReleaseKitProfileDeclaresProductAndDeliverySettings(t *testing.T) {
|
||||
Owner string `json:"owner"`
|
||||
Repository string `json:"repository"`
|
||||
TokenEnv string `json:"token_env"`
|
||||
Forge string `json:"forge"`
|
||||
} `json:"delivery"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &profile); err != nil {
|
||||
@@ -311,7 +55,7 @@ func TestReleaseKitProfileDeclaresProductAndDeliverySettings(t *testing.T) {
|
||||
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" {
|
||||
if profile.Delivery.BaseURL != "https://gitea.maximumdirect.net" || profile.Delivery.Owner != "eric" || profile.Delivery.Repository != "audita" || profile.Delivery.TokenEnv != "RELEASEKIT_GITEA_TOKEN" || profile.Delivery.Forge != "gitea" {
|
||||
t.Fatalf("delivery=%+v", profile.Delivery)
|
||||
}
|
||||
}
|
||||
@@ -337,95 +81,6 @@ func TestReleaseKitTargetMembership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type releaseTestLayout struct {
|
||||
repo string
|
||||
caller string
|
||||
wrapper string
|
||||
argsLog string
|
||||
goLog 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"),
|
||||
goLog: filepath.Join(root, "go-arguments"),
|
||||
path: filepath.Join(root, "bin"),
|
||||
}
|
||||
for _, directory := range []string{filepath.Join(layout.repo, "scripts"), layout.caller, layout.path} {
|
||||
if err := os.MkdirAll(directory, 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)
|
||||
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 (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 {
|
||||
|
||||
Reference in New Issue
Block a user