25 Commits

Author SHA1 Message Date
2180aa868e Prepare the v0.9.0 release notes and retire completed roadmaps 2026-11-04 01:59:54 +00:00
bf4e2329ec Build delivery retries from clean tagged source 2026-11-04 01:52:26 +00:00
bd1c714935 Invoke the registered ReleaseKit package explicitly 2026-11-04 01:51:36 +00:00
f625674a68 Record follow up launcher review findings 2026-11-04 01:42:03 +00:00
9797f43dc4 Anchor smoke checks to the selected checkout 2026-11-04 01:25:50 +00:00
b5c6638538 Preserve replacement selectors during recovery 2026-11-04 01:25:34 +00:00
ec67496569 Align source and standalone tool instructions 2026-11-04 01:24:16 +00:00
d99fac23e9 Explain target variables in configured checks 2026-11-04 01:23:40 +00:00
a3c74188be Use external Go in recovery commands 2026-11-04 01:23:24 +00:00
7dcc9a37c7 Record that host native launchers are implemented 2026-11-04 01:22:27 +00:00
68ce3e5a3b Verify cross built workflow assets 2026-11-04 01:21:58 +00:00
b00bc41b65 Clarify the recovery command target scope 2026-11-04 01:21:28 +00:00
fcec6357ff Make the adoption smoke command runnable 2026-11-04 01:21:20 +00:00
13e1c21386 Record host launcher algorithm audit findings 2026-11-04 01:20:02 +00:00
80a610a5c2 Record host launcher integration findings 2026-11-04 01:10:34 +00:00
d02b9ffeb8 Record host native launcher review findings 2026-11-04 01:02:39 +00:00
0269648429 Document host-native release launchers 2026-11-04 00:54:44 +00:00
bd693ca31b Run recovery handoffs for the host target 2026-11-04 00:52:04 +00:00
d8ec9389e0 Prepare recovery tools for the host target 2026-11-04 00:50:59 +00:00
10028c4abb Use the host target for source-built release tools 2026-11-04 00:49:59 +00:00
8e897d7b3e Use the host target in consumer delivery workflows 2026-11-04 00:49:10 +00:00
a8ffa89011 Use the host target in the multi-module launcher 2026-11-04 00:47:38 +00:00
bf07bed2ae Plan host native release launchers 2026-11-04 00:43:57 +00:00
373f7a69cd Include recovery handoffs in the host launcher roadmap 2026-11-04 00:39:01 +00:00
b1ce5bf976 Plan host-native consumer launcher fixes 2026-11-03 15:48:37 +00:00
19 changed files with 370 additions and 83 deletions

View File

@@ -31,7 +31,9 @@ Run the offline Go test suite from the repository root with:
go test ./... go test ./...
``` ```
The version command prints ReleaseKit's version: The version command prints ReleaseKit's version. For the `go run` examples
below, [select the Go host target](docs/consumers/adoption.md#select-the-go-host-target)
if your shell is set to cross-compile.
```sh ```sh
go run ./cmd/releasekit version go run ./cmd/releasekit version

View File

@@ -142,16 +142,17 @@ func selectedPublicationRecoveryCommand(ctx context.Context, result releasekit.P
if err != nil { if err != nil {
return "", err return "", err
} }
if len(args) > 0 { if args != nil {
command := "env GOWORK=off GOTOOLCHAIN=local go" prefix := "env go -C " + shellQuote(git.DisplayDiagnostic(args.Directory))
for _, arg := range args { command := "(export GOWORK=off GOTOOLCHAIN=local && releasekit_host_os=$(" + prefix + " env GOHOSTOS) && releasekit_host_arch=$(" + prefix + " env GOHOSTARCH) && GOOS=\"$releasekit_host_os\" GOARCH=\"$releasekit_host_arch\" " + prefix
for _, arg := range args.Args {
command += " " + shellQuote(git.DisplayDiagnostic(arg)) command += " " + shellQuote(git.DisplayDiagnostic(arg))
} }
path, err := filepath.Abs(git.DisplayDiagnostic(result.RecordPath)) path, err := filepath.Abs(git.DisplayDiagnostic(result.RecordPath))
if err != nil { if err != nil {
return "", err return "", err
} }
return command + " reconcile --record " + shellQuote(path), nil return command + " reconcile --record " + shellQuote(path) + ")", nil
} }
} }
return publicationRecoveryCommand(result.RecordPath) return publicationRecoveryCommand(result.RecordPath)

View File

@@ -8,6 +8,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -81,6 +82,11 @@ func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) {
shim := `#!/bin/sh shim := `#!/bin/sh
set -eu set -eu
printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG" printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG"
if [ "$1" = env ]; then
case "$2" in GOHOSTOS) printf '%s\n' "$RELEASEKIT_TEST_HOST_OS" ;; GOHOSTARCH) printf '%s\n' "$RELEASEKIT_TEST_HOST_ARCH" ;; esac
exit 0
fi
case " $* " in *" -deps "*) printf 'target=%s/%s\n' "$GOOS" "$GOARCH" >> "$RELEASEKIT_TEST_GO_LOG" ;; esac
modfile= modfile=
for argument in "$@"; do for argument in "$@"; do
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
@@ -98,6 +104,11 @@ fi
t.Setenv("RELEASEKIT_MODULE_MAP", mapPath) t.Setenv("RELEASEKIT_MODULE_MAP", mapPath)
t.Setenv("RELEASEKIT_TEST_GO_LOG", goLog) t.Setenv("RELEASEKIT_TEST_GO_LOG", goLog)
t.Setenv("RELEASEKIT_TEST_TOOL_ARGS", argsLog) t.Setenv("RELEASEKIT_TEST_TOOL_ARGS", argsLog)
t.Setenv("RELEASEKIT_TEST_HOST_OS", runtime.GOOS)
t.Setenv("RELEASEKIT_TEST_HOST_ARCH", runtime.GOARCH)
foreignOS, foreignArch := foreignTarget()
t.Setenv("GOOS", foreignOS)
t.Setenv("GOARCH", foreignArch)
launcher := filepath.Join(repository, "release.sh") launcher := filepath.Join(repository, "release.sh")
command := exec.Command(launcher, "with space", "v1.2.4") command := exec.Command(launcher, "with space", "v1.2.4")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -115,6 +126,9 @@ fi
if !strings.Contains(string(goCommands), "cwd="+wantModule) || !strings.Contains(string(goCommands), "v0.2.299") || strings.Contains(string(goCommands), "v0.2.197") { if !strings.Contains(string(goCommands), "cwd="+wantModule) || !strings.Contains(string(goCommands), "v0.2.299") || strings.Contains(string(goCommands), "v0.2.197") {
t.Fatalf("tool bootstrap did not use selected module metadata: %s", goCommands) t.Fatalf("tool bootstrap did not use selected module metadata: %s", goCommands)
} }
if !strings.Contains(string(goCommands), "target="+runtime.GOOS+"/"+runtime.GOARCH) {
t.Fatalf("dependency inspection did not use host target: %s", goCommands)
}
toolArgs, err := os.ReadFile(argsLog) toolArgs, err := os.ReadFile(argsLog)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -134,7 +148,11 @@ func TestMultiModuleLauncherRunsToolFromSelectedDependency(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
copyExampleTree(t, example, repository) copyExampleTree(t, example, repository)
writeRepositoryFile(t, repository, "go.mod", "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n") writeRepositoryFile(t, repository, "go.mod", "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.198\n") const otherModule = "example.com/other-tool"
other := t.TempDir()
writeRepositoryFile(t, other, "go.mod", "module "+otherModule+"\n\ngo 1.25.0\n")
writeRepositoryFile(t, other, "cmd/releasekit/main.go", "package main\nfunc main() { println(\"unexpected tool ran\") }\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.198\nrequire "+otherModule+" v0.0.0\n\nreplace "+otherModule+" => "+strconv.Quote(other)+"\n\ntool "+otherModule+"/cmd/releasekit\n")
cliGitRun(t, repository, "add", ".") cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "prepare selected tool module") cliGitRun(t, repository, "commit", "-m", "prepare selected tool module")
@@ -152,6 +170,9 @@ func TestMultiModuleLauncherRunsToolFromSelectedDependency(t *testing.T) {
t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxy)) t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxy))
t.Setenv("GOSUMDB", "off") t.Setenv("GOSUMDB", "off")
t.Setenv("GOTOOLCHAIN", "local") t.Setenv("GOTOOLCHAIN", "local")
foreignOS, foreignArch := foreignTarget()
t.Setenv("GOOS", foreignOS)
t.Setenv("GOARCH", foreignArch)
moduleBefore, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod")) moduleBefore, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -183,6 +204,9 @@ func TestMultiModuleLauncherRunsToolFromSelectedDependency(t *testing.T) {
if strings.Contains(string(output), "tool-version=v0.2.197") { if strings.Contains(string(output), "tool-version=v0.2.197") {
t.Fatalf("launcher ran the root-selected ReleaseKit version: %s", output) t.Fatalf("launcher ran the root-selected ReleaseKit version: %s", output)
} }
if strings.Contains(string(output), "unexpected tool ran") {
t.Fatalf("launcher ran the other tool with the same name: %s", output)
}
moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod")) moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
if err != nil || !bytes.Equal(moduleAfter, moduleBefore) { if err != nil || !bytes.Equal(moduleAfter, moduleBefore) {
t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err) t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err)
@@ -203,6 +227,17 @@ func TestMultiModuleLauncherRunsToolFromSelectedDependency(t *testing.T) {
} }
} }
func foreignTarget() (string, string) {
osName, arch := "windows", "arm64"
if runtime.GOOS == osName {
osName = "linux"
}
if runtime.GOARCH == arch {
arch = "amd64"
}
return osName, arch
}
func runModuleSelector(t *testing.T, helper, mode, value, mapPath string) string { func runModuleSelector(t *testing.T, helper, mode, value, mapPath string) string {
t.Helper() t.Helper()
command := exec.Command("sh", helper, mode, value) command := exec.Command("sh", helper, mode, value)

View File

@@ -1,6 +1,8 @@
package main package main
import ( import (
"bytes"
"debug/elf"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -11,6 +13,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -55,6 +58,7 @@ func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
if output := runWoodpeckerBlock(t, command, repository, env); !strings.Contains(output, "state complete") || !strings.Contains(output, "tool-version=v0.2.197") { if output := runWoodpeckerBlock(t, command, repository, env); !strings.Contains(output, "state complete") || !strings.Contains(output, "tool-version=v0.2.197") {
t.Fatalf("tagged page delivery output = %q", output) t.Fatalf("tagged page delivery output = %q", output)
} }
assertGoSelection(t, repository, env["RELEASEKIT_GO_LOG"])
if delivery.snapshot().creates != 1 || delivery.snapshot().requests == 0 || delivery.snapshot().tag != tag { if delivery.snapshot().creates != 1 || delivery.snapshot().requests == 0 || delivery.snapshot().tag != tag {
t.Fatalf("page delivery fixture = %#v", delivery.snapshot()) t.Fatalf("page delivery fixture = %#v", delivery.snapshot())
} }
@@ -77,11 +81,15 @@ func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
t.Run("binary delivery from physical major module", func(t *testing.T) { t.Run("binary delivery from physical major module", func(t *testing.T) {
const tag = "integrations/core/v2.1.0" const tag = "integrations/core/v2.1.0"
arch, machine := "arm64", elf.EM_AARCH64
if runtime.GOOS == "linux" && runtime.GOARCH == arch {
arch, machine = "amd64", elf.EM_X86_64
}
delivery := newMappedDeliveryFixture(t, tag) delivery := newMappedDeliveryFixture(t, tag)
repository := newWorkflowRepository(t, example) repository := newWorkflowRepository(t, example)
writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{ writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{
Product: "Example Consumer Core v2", ModuleDir: "integrations/core/v2", Product: "Example Consumer Core v2", ModuleDir: "integrations/core/v2",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "core", Targets: []string{"linux/amd64"}}, Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "core", Targets: []string{"linux/" + arch}},
Delivery: workflowDeliverySettings(delivery.snapshot().URL), Delivery: workflowDeliverySettings(delivery.snapshot().URL),
}) })
writeRepositoryFile(t, repository, "integrations/core/v2/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nTagged core note.\n") writeRepositoryFile(t, repository, "integrations/core/v2/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nTagged core note.\n")
@@ -97,8 +105,17 @@ func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
if observed.creates != 1 || observed.uploads != 2 || observed.tag != tag { if observed.creates != 1 || observed.uploads != 2 || observed.tag != tag {
t.Fatalf("binary delivery fixture = %#v", observed) t.Fatalf("binary delivery fixture = %#v", observed)
} }
if _, ok := observed.assets["core-v2.1.0-linux-amd64"]; !ok { name := "core-v2.1.0-linux-" + arch
t.Fatalf("binary asset missing: %#v", observed.assets) asset, ok := observed.assets[name]
if !ok {
t.Fatalf("binary asset %q missing", name)
}
binary, err := elf.NewFile(bytes.NewReader(asset))
if err != nil {
t.Fatalf("read uploaded %s as ELF: %v", name, err)
}
if binary.Machine != machine {
t.Fatalf("uploaded %s machine = %s, want %s", name, binary.Machine, machine)
} }
if _, ok := observed.assets["SHA256SUMS"]; !ok { if _, ok := observed.assets["SHA256SUMS"]; !ok {
t.Fatalf("checksum asset missing: %#v", observed.assets) t.Fatalf("checksum asset missing: %#v", observed.assets)
@@ -400,6 +417,7 @@ func multiModuleWorkflowEnvironment(t *testing.T, repository, commit, tag, binar
t.Helper() t.Helper()
goDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
environment := templateEnvironment(repository, commit, goDir, goLog) environment := templateEnvironment(repository, commit, goDir, goLog)
environment["GOOS"], environment["GOARCH"] = foreignTarget()
environment["CI_COMMIT_TAG"] = tag environment["CI_COMMIT_TAG"] = tag
environment["RELEASEKIT_TEST_BINARY"] = binary environment["RELEASEKIT_TEST_BINARY"] = binary
useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy)) useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy))

View File

@@ -15,6 +15,8 @@ import (
"testing" "testing"
"time" "time"
"golang.org/x/mod/modfile"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
"go.maximumdirect.net/releasekit/internal/gotool" "go.maximumdirect.net/releasekit/internal/gotool"
"go.maximumdirect.net/releasekit/internal/publish" "go.maximumdirect.net/releasekit/internal/publish"
@@ -57,8 +59,8 @@ func TestVendoredAPIOnlyPublicationRecovery(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
launcher := exec.Command(goBinary, append(launcherArgs, "publish", "--repo", repo, "v1.2.3")...) launcher := exec.Command(goBinary, append(launcherArgs.Args, "publish", "--repo", repo, "v1.2.3")...)
launcher.Dir = t.TempDir() launcher.Dir = launcherArgs.Directory
publicationOutput, err := launcher.CombinedOutput() publicationOutput, err := launcher.CombinedOutput()
var exitError *exec.ExitError var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 { if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
@@ -98,12 +100,29 @@ func TestRecoveryToolPreservesVersionedReplacement(t *testing.T) {
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte(data), 0600); err != nil { if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte(data), 0600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
shimDir := t.TempDir()
logPath := filepath.Join(t.TempDir(), "target")
realGo := filepath.Join(runtime.GOROOT(), "bin", "go")
shim := "#!/bin/sh\ncase \" $* \" in *\" -deps \"*) printf '%s/%s\\n' \"$GOOS\" \"$GOARCH\" > \"$RECOVERY_TARGET_LOG\" ;; esac\nexec \"$RECOVERY_REAL_GO\" \"$@\"\n"
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("RECOVERY_TARGET_LOG", logPath)
t.Setenv("RECOVERY_REAL_GO", realGo)
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
foreignOS, foreignArch := foreignTarget()
t.Setenv("GOOS", foreignOS)
t.Setenv("GOARCH", foreignArch)
args, err := gotool.RecoveryTool(t.Context(), module, t.TempDir()) args, err := gotool.RecoveryTool(t.Context(), module, t.TempDir())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...) if target, err := os.ReadFile(logPath); err != nil || strings.TrimSpace(string(target)) != runtime.GOOS+"/"+runtime.GOARCH {
command.Dir = t.TempDir() t.Fatalf("recovery preparation target = %q, %v", target, err)
}
command := exec.Command(realGo, args.Args...)
command.Dir = args.Directory
command.Env = setEnvironment(setEnvironment(os.Environ(), "GOOS", runtime.GOOS), "GOARCH", runtime.GOARCH)
output, err := command.CombinedOutput() output, err := command.CombinedOutput()
if err != nil || !strings.Contains(string(output), "tool-version=v0.2.198 helper=tool-only dependency") { if err != nil || !strings.Contains(string(output), "tool-version=v0.2.198 helper=tool-only dependency") {
t.Fatalf("versioned recovery selection: %v: %s", err, output) t.Fatalf("versioned recovery selection: %v: %s", err, output)
@@ -119,8 +138,8 @@ func TestRecoveryToolSupportsReleaseKitSourceModule(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args, "version")...) command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args.Args, "version")...)
command.Dir = t.TempDir() command.Dir = args.Directory
output, err := command.CombinedOutput() output, err := command.CombinedOutput()
if err != nil || !strings.HasPrefix(string(output), "releasekit ") { if err != nil || !strings.HasPrefix(string(output), "releasekit ") {
t.Fatalf("source module recovery entry point: %v: %s", err, output) t.Fatalf("source module recovery entry point: %v: %s", err, output)
@@ -142,16 +161,25 @@ func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
if err := os.Symlink(source, selectedSource); err != nil { if err := os.Symlink(source, selectedSource); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !registered { const toolModule = "go.maximumdirect.net/releasekit"
relative, err := filepath.Rel(repo, selectedSource) const toolVersion = "v0.0.0"
module := "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire " + toolModule + " " + toolVersion + "\n"
var wildcardSource string
if registered {
module += "\nreplace " + toolModule + " => " + strconv.Quote(selectedSource) + "\n"
module += "\ntool " + toolModule + "/cmd/releasekit\n"
} else {
wildcardSource = t.TempDir()
writeRepositoryFile(t, wildcardSource, "go.mod", "module "+toolModule+"\n\ngo 1.26.0\n")
wildcard, err := filepath.Rel(repo, wildcardSource)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
selectedSource = relative specific, err := filepath.Rel(repo, selectedSource)
if err != nil {
t.Fatal(err)
} }
module := "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => " + strconv.Quote(selectedSource) + "\n" module += "\nreplace (\n" + toolModule + " => " + strconv.Quote(wildcard) + "\n" + toolModule + " " + toolVersion + " => " + strconv.Quote(specific) + "\n)\n"
if registered {
module += "\ntool go.maximumdirect.net/releasekit/cmd/releasekit\n"
} }
writeRepositoryFile(t, repo, "go.mod", module) writeRepositoryFile(t, repo, "go.mod", module)
sums, err := os.ReadFile(filepath.Join(source, "go.sum")) sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
@@ -234,12 +262,39 @@ func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
if !strings.Contains(command, "go ") || strings.Contains(command, "/exe/releasekit") { if !strings.Contains(command, "go ") || strings.Contains(command, "/exe/releasekit") {
t.Fatalf("unstable handoff: %s", command) t.Fatalf("unstable handoff: %s", command)
} }
if !registered && !strings.Contains(command, "publication-recovery") { if !registered {
store := filepath.Join(repo, ".git", "releasekit", "publication-recovery")
entries, err := os.ReadDir(store)
if err != nil || len(entries) != 1 {
t.Fatalf("API-only recovery modules = %v, %v", entries, err)
}
dir := filepath.Join(store, entries[0].Name())
if !strings.Contains(command, shellQuote(dir)) {
t.Fatalf("API-only handoff lacks durable metadata: %s", command) t.Fatalf("API-only handoff lacks durable metadata: %s", command)
} }
path := filepath.Join(dir, "go.mod")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
prepared, err := modfile.Parse(path, data, nil)
if err != nil {
t.Fatal(err)
}
paths := map[string]string{}
for _, replacement := range prepared.Replace {
if replacement.Old.Path == toolModule {
paths[replacement.Old.Version] = replacement.New.Path
}
}
if len(paths) != 2 || paths[""] != wildcardSource || paths[toolVersion] != selectedSource {
t.Fatalf("durable recovery lost replacement selection: %v", paths)
}
}
// Execute from another directory after the original driver and the // Execute from another directory after the original driver and the
// API-only launcher's temporary files are gone. // API-only launcher's temporary files are gone.
evidence, err := publish.LoadEvidence(outputLineValue(text, "publication record saved: ")) originalRecord := outputLineValue(text, "publication record saved: ")
evidence, err := publish.LoadEvidence(originalRecord)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -260,10 +315,13 @@ func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
} }
selected := command selected := command
if scenario.record != "" { if scenario.record != "" {
selected += " --record " + shellQuote(scenario.record) selected = strings.Replace(selected, shellQuote(originalRecord), shellQuote(scenario.record), 1)
} }
recovery := exec.Command("sh", "-c", selected) foreignOS, foreignArch := foreignTarget()
goFunction := "go() { GOOS=" + shellQuote(foreignOS) + " GOARCH=" + shellQuote(foreignArch) + " command go \"$@\"; }\n"
recovery := exec.Command("sh", "-c", goFunction+selected)
recovery.Dir = t.TempDir() recovery.Dir = t.TempDir()
recovery.Env = setEnvironment(setEnvironment(os.Environ(), "GOOS", foreignOS), "GOARCH", foreignArch)
recovered, err := recovery.CombinedOutput() recovered, err := recovery.CombinedOutput()
if scenario.status == 0 && err != nil || scenario.status != 0 && (!errors.As(err, &exitError) || exitError.ExitCode() != scenario.status) { if scenario.status == 0 && err != nil || scenario.status != 0 && (!errors.As(err, &exitError) || exitError.ExitCode() != scenario.status) {
t.Fatalf("recovery status want %d: %v: %s", scenario.status, err, recovered) t.Fatalf("recovery status want %d: %v: %s", scenario.status, err, recovered)

View File

@@ -46,6 +46,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
goDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog) env := templateEnvironment(repository, commit, goDir, goLog)
env["GOOS"], env["GOARCH"] = foreignTarget()
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
output := runWoodpeckerBlock(t, command, repository, env) output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "state complete") { if !strings.Contains(output, "state complete") {
@@ -77,6 +78,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
goDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog) env := templateEnvironment(repository, commit, goDir, goLog)
env["GOOS"], env["GOARCH"] = foreignTarget()
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum")) sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum"))
for attempt := 0; attempt < 2; attempt++ { for attempt := 0; attempt < 2; attempt++ {
@@ -108,6 +110,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
unrelated := cliGitOutput(t, repository, "rev-parse", "HEAD") unrelated := cliGitOutput(t, repository, "rev-parse", "HEAD")
goDir, goLog := installControlledGoBootstrapShim(t) goDir, goLog := installControlledGoBootstrapShim(t)
env := templateEnvironment(repository, unrelated, goDir, goLog) env := templateEnvironment(repository, unrelated, goDir, goLog)
env["GOOS"], env["GOARCH"] = foreignTarget()
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
t.Fatalf("wrong tag succeeded: %s", output) t.Fatalf("wrong tag succeeded: %s", output)
} else if !strings.Contains(output, commit) || !strings.Contains(output, unrelated) { } else if !strings.Contains(output, commit) || !strings.Contains(output, unrelated) {
@@ -123,6 +126,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
writeRepositoryFile(t, repository, "go.mod", "module example.com/different-tool-selection\n\ngo 1.25.0\n") writeRepositoryFile(t, repository, "go.mod", "module example.com/different-tool-selection\n\ngo 1.25.0\n")
goDir, goLog := installControlledGoBootstrapShim(t) goDir, goLog := installControlledGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog) env := templateEnvironment(repository, commit, goDir, goLog)
env["GOOS"], env["GOARCH"] = foreignTarget()
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
t.Fatalf("dirty module succeeded: %s", output) t.Fatalf("dirty module succeeded: %s", output)
} }
@@ -153,6 +157,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
goDir, goLog := installControlledGoBootstrapShim(t) goDir, goLog := installControlledGoBootstrapShim(t)
toolArgs := filepath.Join(t.TempDir(), "tool-arguments") toolArgs := filepath.Join(t.TempDir(), "tool-arguments")
env := templateEnvironment(repository, strings.ToUpper(eventID), goDir, goLog) env := templateEnvironment(repository, strings.ToUpper(eventID), goDir, goLog)
env["GOOS"], env["GOARCH"] = foreignTarget()
env["CI_COMMIT_TAG"] = event.tag env["CI_COMMIT_TAG"] = event.tag
env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs
env["RELEASEKIT_TOOL_EXIT"] = "37" env["RELEASEKIT_TOOL_EXIT"] = "37"
@@ -225,6 +230,7 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
commit := oldCommit commit := oldCommit
env := templateEnvironment(repository, commit, goDir, goLog) env := templateEnvironment(repository, commit, goDir, goLog)
env["GOOS"], env["GOARCH"] = foreignTarget()
useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy)) useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy))
sumLog := filepath.Join(t.TempDir(), "tool-only-sums") sumLog := filepath.Join(t.TempDir(), "tool-only-sums")
env["RELEASEKIT_SUM_LOG"] = sumLog env["RELEASEKIT_SUM_LOG"] = sumLog
@@ -242,6 +248,7 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
const module = "go.maximumdirect.net/releasekit" const module = "go.maximumdirect.net/releasekit"
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n\ntool "+module+"/cmd/releasekit\n") writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n\ntool "+module+"/cmd/releasekit\n")
env := templateEnvironment(repository, "", goDir, goLog) env := templateEnvironment(repository, "", goDir, goLog)
env["GOOS"], env["GOARCH"] = foreignTarget()
useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy)) useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy))
runWoodpeckerBlock(t, "go mod tidy\ngo mod vendor", repository, env) runWoodpeckerBlock(t, "go mod tidy\ngo mod vendor", repository, env)
cliGitRun(t, repository, "add", "go.mod", "go.sum", "vendor") cliGitRun(t, repository, "add", "go.mod", "go.sum", "vendor")
@@ -267,6 +274,7 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
commit := pushTaggedCLIRepository(t, repository) commit := pushTaggedCLIRepository(t, repository)
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
env := templateEnvironment(repository, commit, goDir, goLog) env := templateEnvironment(repository, commit, goDir, goLog)
env["GOOS"], env["GOARCH"] = foreignTarget()
useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy)) useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy))
sumLog := filepath.Join(t.TempDir(), "tool-only-sums") sumLog := filepath.Join(t.TempDir(), "tool-only-sums")
env["RELEASEKIT_SUM_LOG"] = sumLog env["RELEASEKIT_SUM_LOG"] = sumLog
@@ -313,6 +321,7 @@ func TestWoodpeckerSourceDeliveryTemplateSelfBootstraps(t *testing.T) {
commit := pushTaggedCLIRepository(t, repository) commit := pushTaggedCLIRepository(t, repository)
goDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog) env := templateEnvironment(repository, commit, goDir, goLog)
env["GOOS"], env["GOARCH"] = foreignTarget()
useSyntheticModuleProxy(t, env, proxy.URL) useSyntheticModuleProxy(t, env, proxy.URL)
proxyRequests.Store(0) proxyRequests.Store(0)
env["CI_COMMIT_SHA"] = strings.Repeat("0", len(commit)) env["CI_COMMIT_SHA"] = strings.Repeat("0", len(commit))
@@ -335,6 +344,9 @@ func TestWoodpeckerSourceDeliveryTemplateSelfBootstraps(t *testing.T) {
t.Fatalf("self-bootstrapped command arguments = %q", output) t.Fatalf("self-bootstrapped command arguments = %q", output)
} }
log := readWoodpeckerFixtureFile(t, goLog) log := readWoodpeckerFixtureFile(t, goLog)
if !strings.Contains(log, "build-target="+runtime.GOOS+"/"+runtime.GOARCH) {
t.Fatalf("source build did not use host target: %s", log)
}
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, "build -mod=readonly -o ") || !strings.Contains(log, "./cmd/releasekit") { if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, "build -mod=readonly -o ") || !strings.Contains(log, "./cmd/releasekit") {
t.Fatalf("self-bootstrap Go command = %q", log) t.Fatalf("self-bootstrap Go command = %q", log)
} }
@@ -418,6 +430,8 @@ func installGoBootstrapShim(t *testing.T) (string, string) {
goLog := filepath.Join(shimDir, "go-commands") goLog := filepath.Join(shimDir, "go-commands")
shim := `#!/bin/sh shim := `#!/bin/sh
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG" printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
case " $* " in *" -deps "*) printf 'target=%s/%s\n' "$GOOS" "$GOARCH" >> "$RELEASEKIT_GO_LOG" ;; esac
if [ "$1" = build ]; then printf 'build-target=%s/%s\n' "$GOOS" "$GOARCH" >> "$RELEASEKIT_GO_LOG"; fi
"$RELEASEKIT_REAL_GO" "$@" "$RELEASEKIT_REAL_GO" "$@"
status=$? status=$?
if [ "$status" -eq 0 ] && [ "$1" = "tool" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then if [ "$status" -eq 0 ] && [ "$1" = "tool" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then
@@ -442,6 +456,8 @@ func installControlledGoBootstrapShim(t *testing.T) (string, string) {
shim := `#!/bin/sh shim := `#!/bin/sh
set -eu set -eu
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG" printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
if [ "$1" = env ]; then exec "$RELEASEKIT_REAL_GO" "$@"; fi
case " $* " in *" -deps "*) printf 'target=%s/%s\n' "$GOOS" "$GOARCH" >> "$RELEASEKIT_GO_LOG" ;; esac
modfile= modfile=
for argument in "$@"; do for argument in "$@"; do
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
@@ -644,13 +660,16 @@ func assertModuleBootstrap(t *testing.T, repository, goLog, moduleBefore string,
func assertGoSelection(t *testing.T, repository, goLog string) { func assertGoSelection(t *testing.T, repository, goLog string) {
t.Helper() t.Helper()
log := readWoodpeckerFixtureFile(t, goLog) log := readWoodpeckerFixtureFile(t, goLog)
if !strings.Contains(log, "target="+runtime.GOOS+"/"+runtime.GOARCH) {
t.Fatalf("dependency inspection did not use host target: %s", log)
}
list := "list -mod=mod -modfile=" list := "list -mod=mod -modfile="
tool := "tool -modfile=" tool := "tool -modfile="
module := "go.maximumdirect.net/releasekit" module := "go.maximumdirect.net/releasekit"
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) { if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) {
t.Fatalf("Go module selection command is missing required settings: %q", log) t.Fatalf("Go module selection command is missing required settings: %q", log)
} }
if !strings.Contains(log, tool) || !strings.Contains(log, " releasekit ci-deliver ") || strings.Contains(log, "@v0.2.0") { if !strings.Contains(log, tool) || !strings.Contains(log, " "+module+"/cmd/releasekit ci-deliver ") || strings.Contains(log, "@v0.2.0") {
t.Fatalf("Go tool did not run the module-selected ReleaseKit: %q", log) t.Fatalf("Go tool did not run the module-selected ReleaseKit: %q", log)
} }
if strings.Index(log, list) > strings.Index(log, tool) { if strings.Index(log, list) > strings.Index(log, tool) {

View File

@@ -4,6 +4,8 @@ ReleaseKit currently provides eight commands. The exit statuses below belong
to the native executable. `go run` examples are convenient for source execution, to the native executable. `go run` examples are convenient for source execution,
but the Go driver maps a nonzero child exit status to 1. Use the built executable but the Go driver maps a nonzero child exit status to 1. Use the built executable
or `./scripts/release` for publication when distinct statuses are needed. or `./scripts/release` for publication when distinct statuses are needed.
For the `go run` examples, [select the Go host target](consumers/adoption.md#select-the-go-host-target)
before running from a shell set to cross-compile.
Print the executable version: Print the executable version:
@@ -154,7 +156,9 @@ uses a persistent native executable when available. Recognized temporary Go-buil
executables provide no native fallback after their driver exits. Command executables provide no native fallback after their driver exits. Command
preparation has its own bounded context after a failed publication, so publication preparation has its own bounded context after a failed publication, so publication
cancellation permits preparing the later invocation. Each entry point preserves cancellation permits preparing the later invocation. Each entry point preserves
the recovery exit status. the recovery exit status. Run a generated Go-tool handoff in a POSIX shell.
When it runs, it sets `GOOS` and `GOARCH` to the selected Go toolchain's
`GOHOSTOS` and `GOHOSTARCH`.
If the selected entry point becomes unavailable, use another supported ReleaseKit If the selected entry point becomes unavailable, use another supported ReleaseKit
CLI with the saved record. CLI with the saved record.
@@ -338,11 +342,12 @@ status 2. Final delivery uses the same statuses as `deliver`: 0 for complete,
state, including when cancellation prevents reconciliation. state, including when cancellation prevents reconciliation.
The maintained module-based tag workflow verifies and checks out the event tag The maintained module-based tag workflow verifies and checks out the event tag
before resolving the consumer module. It runs `go tool releasekit` for the version before resolving the consumer module. It invokes the ReleaseKit tool by its full
already selected by that tagged `go.mod`, using temporary alternate module files package path for the version already selected by that tagged `go.mod`, using
outside the source checkout. This tool bootstrap is separate from `ci-deliver`'s temporary alternate module files outside the source checkout. This tool bootstrap
application dependency preparation for binary profiles. ReleaseKit's own is separate from `ci-deliver`'s application dependency preparation for binary
source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared profiles. ReleaseKit's own source workflow self-bootstraps `./cmd/releasekit`;
consumers without a declared
ReleaseKit module dependency can use the separately installed-tool command. ReleaseKit module dependency can use the separately installed-tool command.
See the [delivery guide](consumers/delivery.md#woodpecker-tag-workflows) for See the [delivery guide](consumers/delivery.md#woodpecker-tag-workflows) for
compatible Go environments, retries, and preparation details. compatible Go environments, retries, and preparation details.

View File

@@ -12,7 +12,6 @@ From the consumer module's root, select the ReleaseKit version to adopt:
: "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}" : "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
go get -tool "go.maximumdirect.net/releasekit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}" go get -tool "go.maximumdirect.net/releasekit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
go mod tidy go mod tidy
go tool releasekit version
``` ```
Select a release that implements the API, profile, and workflow you are adopting. Select a release that implements the API, profile, and workflow you are adopting.
@@ -33,18 +32,32 @@ participate in dependency version selection and can raise the consumer module's
Go minimum. See the Go minimum. See the
[Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool). [Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool).
Run `go tool releasekit` from within the consumer module to use its selected ### Select the Go host target
version. Go builds and caches the executable and preserves its exit status.
A launcher invoked from that module can forward commands directly: Invoke the ReleaseKit tool by its full package path from within the consumer
module to use its selected version. Go builds and caches the executable and
preserves its exit status. The full path distinguishes tools with the same
short name. A launcher can forward commands directly:
```sh ```sh
#!/bin/sh #!/bin/sh
exec go tool releasekit "$@" releasekit_host_os=$(go env GOHOSTOS) || exit $?
releasekit_host_arch=$(go env GOHOSTARCH) || exit $?
export GOOS="$releasekit_host_os" GOARCH="$releasekit_host_arch"
exec go tool go.maximumdirect.net/releasekit/cmd/releasekit "$@"
``` ```
Save this launcher as `releasekit` and run `sh ./releasekit version` from the
selected module to verify registration. It preserves the consumer's workspace
and toolchain settings while selecting the host for the executable.
For a launcher callable from other directories, first change to its consumer For a launcher callable from other directories, first change to its consumer
module directory. Follow the [CLI contract](../cli.md) for commands and exit module directory. Follow the [CLI contract](../cli.md) for commands and exit
statuses, and the [configuration reference](../config.md) for the release profile. statuses.
Configured product checks inherit the launcher's host `GOOS` and `GOARCH`.
If a check needs an application target, set it in that check's `env` map or
command. The [configuration reference](../config.md) describes
check environments and the separate target selection for built-in builds.
For consumers using vendoring, register the CLI before running `go mod vendor` For consumers using vendoring, register the CLI before running `go mod vendor`
so the vendor tree includes its dependencies. `go tool` follows Go's normal so the vendor tree includes its dependencies. `go tool` follows Go's normal
@@ -102,12 +115,16 @@ Go module identity. The Go consumer minimum remains 1.26.0.
## CI adoption ## CI adoption
The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows) The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows)
supports both tool and ordinary package dependencies. It runs `go tool releasekit` supports both tool and ordinary package dependencies. It runs the ReleaseKit
with temporary module metadata derived from the tagged consumer's module tool by its full package path with temporary metadata derived from the tagged
selection, so the committed module files also govern CI delivery and retries. module, so the committed module files also govern CI delivery and retries.
Registering the CLI as a tool is sufficient for Registering the CLI as a tool is sufficient for
consumers that use ReleaseKit only for maintainer commands. consumers that use ReleaseKit only for maintainer commands.
Update copied launchers and workflow templates separately when upgrading the
module dependency. ReleaseKit tests the generic bootstrap; consumer wiring tests
can focus on selectors and profile mapping. Profiles continue to select
application build targets.
Upgrade the module-selected tool and workflow together. For a tag created before Upgrade the module-selected tool and workflow together. For a tag created before
that upgrade, retry with the workflow matching the tool recorded at that tag. that upgrade, retry with the workflow matching the tool recorded at that tag.
Keep its immutable module files and tag intact. See the Keep its immutable module files and tag intact. See the

View File

@@ -293,11 +293,13 @@ identify an annotated tag object or a commit. The template requires the resolved
commits to match, checks out that commit detached, and confirms the source tree commits to match, checks out that commit detached, and confirms the source tree
is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary
directory outside the checkout. `go list -m` confirms that the module selects directory outside the checkout. `go list -m` confirms that the module selects
ReleaseKit. The template adds its CLI tool declaration to that temporary copy ReleaseKit. The template selects the host Go target before preparing and running
the CLI. The template adds its CLI tool declaration to that temporary copy
and prepares tool dependencies with `go list -deps`. It then uses and prepares tool dependencies with `go list -deps`. It then uses
`go tool -modfile=... releasekit` to build, cache, and run the selected tool with `go tool -modfile=... go.maximumdirect.net/releasekit/cmd/releasekit` to build,
`CI_WORKSPACE`, the resolved event commit, and `CI_COMMIT_TAG`. The tagged module cache, and run the selected tool with `CI_WORKSPACE`, the resolved event commit,
files stay unchanged, including for consumers with an ordinary package and `CI_COMMIT_TAG`. The tagged module files stay unchanged, including for
consumers with an ordinary package
dependency. A cleanup trap removes the temporary metadata; Go manages the dependency. A cleanup trap removes the temporary metadata; Go manages the
cached executable. The command preserves the tool's exit status. cached executable. The command preserves the tool's exit status.
@@ -336,10 +338,21 @@ and application artifact compilation retain their separate environments.
The normal Go module and build caches are sufficient. Cache mounts may be added The normal Go module and build caches are sufficient. Cache mounts may be added
as deployment configuration when they help the runner; they are not needed by as deployment configuration when they help the runner; they are not needed by
the template. A separately installed executable can be installed with the template. For a standalone installation, replace `PIN` with the desired
`go install go.maximumdirect.net/releasekit/cmd/releasekit@PIN`, then run version and [select the Go host target](adoption.md#select-the-go-host-target)
with the same arguments. With the event object available in the CI checkout, for `go install`:
resolve it to its source commit before invocation:
```sh
(
releasekit_host_os=$(go env GOHOSTOS) || exit $?
releasekit_host_arch=$(go env GOHOSTARCH) || exit $?
export GOOS="$releasekit_host_os" GOARCH="$releasekit_host_arch"
go install go.maximumdirect.net/releasekit/cmd/releasekit@PIN
)
```
With the event object available in the CI checkout, resolve it to its source
commit before invoking the installed executable:
```sh ```sh
expected_commit=$(git -C "$CI_WORKSPACE" rev-parse --verify "${CI_COMMIT_SHA}^{commit}") expected_commit=$(git -C "$CI_WORKSPACE" rev-parse --verify "${CI_COMMIT_SHA}^{commit}")

View File

@@ -12,9 +12,14 @@ For CLI publication recovery, the package selects a registered module tool or
prepares a durable private module root for an API-only consumer. Go's module prepares a durable private module root for an API-only consumer. Go's module
graph query includes transitive dependencies; preparation retains module graph query includes transitive dependencies; preparation retains module
requirements, versioned replacements, and checksums. Local replacements resolve requirements, versioned replacements, and checksums. Local replacements resolve
against their original module directory before relocation. The original main against their original module directory before relocation. Each relocation
module remains a local replacement, including when releasing ReleaseKit itself. updates the directive's destination and retains its path and version selector.
The original main module remains a local replacement, including when releasing
ReleaseKit itself.
The private root provides tool resolution independently of consumer vendoring. The private root provides tool resolution independently of consumer vendoring.
API-only preparation discovers the host target for dependency inspection. The
returned invocation carries its directory and tool arguments; the CLI's later
shell command selects the host again when it runs.
The CLI formats the later invocation using a separate bounded context after The CLI formats the later invocation using a separate bounded context after
publication failure. It falls back to a persistent native executable when module publication failure. It falls back to a persistent native executable when module
selection is unavailable or preparation fails, and excludes recognized temporary selection is unavailable or preparation fails, and excludes recognized temporary

View File

@@ -188,19 +188,35 @@ Woodpecker workflow builds the CLI from the tagged checkout, then uses
destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml` destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml`
and add the Woodpecker repository secret `releasekit_gitea_token`. If page and add the Woodpecker repository secret `releasekit_gitea_token`. If page
delivery fails or is uncertain, inspect the hosted release and retry the same delivery fails or is uncertain, inspect the hosted release and retry the same
immutable version from its tag: immutable version from its tag. The retry requires a clean tagged checkout
before preparing or building the tool:
```sh ```sh
git fetch origin "refs/tags/vX.Y.Z:refs/tags/vX.Y.Z" (
git checkout --detach "vX.Y.Z" set -e
version=vX.Y.Z
tag_ref="refs/tags/$version"
git fetch origin "$tag_ref:$tag_ref"
tag_commit=$(git rev-parse --verify "$tag_ref^{commit}")
git checkout --detach "$tag_commit"
worktree_status=$(git status --porcelain --untracked-files=all)
if [ -n "$worktree_status" ]; then
printf '%s\n' 'Delivery retry requires a clean tagged checkout.' >&2
exit 1
fi
releasekit_host_os=$(GOTOOLCHAIN=local go env GOHOSTOS)
releasekit_host_arch=$(GOTOOLCHAIN=local go env GOHOSTARCH)
export GOOS="$releasekit_host_os" GOARCH="$releasekit_host_arch"
GOTOOLCHAIN=local go mod download GOTOOLCHAIN=local go mod download
mkdir -p .releasekit/bin mkdir -p .releasekit/bin
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit
.releasekit/bin/releasekit deliver "vX.Y.Z" .releasekit/bin/releasekit deliver "$version"
)
``` ```
For an older tag whose CLI predates `token_file`, supply the token through its For an older tag whose CLI predates `token_file`, build the tagged executable
environment variable when retrying that tagged tool: as above, then supply the token through its environment variable when retrying:
```sh ```sh
RELEASEKIT_GITEA_TOKEN="$(cat .releasekit/gitea-token)" \ RELEASEKIT_GITEA_TOKEN="$(cat .releasekit/gitea-token)" \

41
docs/releases/v0.9.0.md Normal file
View File

@@ -0,0 +1,41 @@
# ReleaseKit v0.9.0
ReleaseKit's maintained launchers and generated Go recovery commands now
prepare and run the release tool for the Go host when inherited `GOOS` and
`GOARCH` select another target. Release profiles still select the targets for
application builds.
## Launcher, delivery, and recovery fixes
- Local launchers and Woodpecker templates select the host before preparing
tool dependencies or compiling the CLI. Module-based launchers invoke
ReleaseKit by its full Go package path, so another registered tool named
`releasekit` does not make the invocation ambiguous.
- Generated Go recovery commands select the host when run in a POSIX shell
and find the Go executable through `PATH`. Recovery for API-only consumers
preserves both wildcard and version-specific module replacements when
relocating relative paths, fixing a panic after a failed publication.
- The manual hosted-page retry stops if fetching or checking out the tag fails,
or if local edits remain. It checks the tagged checkout before downloading
dependencies or building the CLI.
## Compatibility and upgrade
The module path remains `go.maximumdirect.net/releasekit` and the Go consumer
minimum remains 1.26.0. Public Go APIs, release profiles, and publication
record formats are unchanged.
Update copied launchers and CI templates to get the host and tool selection
fixes. Updating the module dependency alone does not update those files.
Configured product checks inherit the launcher's host target; if a check
needs an application target, set it explicitly in the check's `env` map
or command.
See the [adoption guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/adoption.md),
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/delivery.md),
and [maintainer runbook](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/release.md)
for updated recipes.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/oss/attribution.md)

View File

@@ -73,9 +73,9 @@ Then select a module and semantic version with the shared launcher:
The launcher copies the selected module's `go.mod` and `go.sum` to temporary The launcher copies the selected module's `go.mod` and `go.sum` to temporary
alternate module files outside the checkout, resolves ReleaseKit from that alternate module files outside the checkout, resolves ReleaseKit from that
module's local dependency selection, adds the CLI tool declaration to that module's local dependency selection, adds the CLI tool declaration to that
temporary copy, and prepares its dependencies. It uses `go tool releasekit` temporary copy, and prepares its dependencies. It invokes the tool by its full
with the alternate module file to invoke `publish` with the repository profile package path with the alternate module file to run `publish` with the repository
and `--expected-module` so the library can verify that the profile agrees with profile and `--expected-module` so the library can verify that the profile agrees with
the selected table row. Go builds and caches the executable and preserves its the selected table row. Go builds and caches the executable and preserves its
exit status; the launcher removes the temporary metadata on exit. As with any exit status; the launcher removes the temporary metadata on exit. As with any
`publish` invocation, commit the selected module's release note and prepare its `publish` invocation, commit the selected module's release note and prepare its
@@ -89,9 +89,9 @@ This declaration does not infer dependency order or rerun the standard suite.
The maintained [Woodpecker example](woodpecker.yml) uses the same map and The maintained [Woodpecker example](woodpecker.yml) uses the same map and
selector for root, `ckconfig`, and major-version tags. It verifies and detaches selector for root, `ckconfig`, and major-version tags. It verifies and detaches
the literal event tag before selecting the module, copies that module's metadata the literal event tag before selecting the module, copies that module's metadata
outside the checkout, and runs the ReleaseKit version selected there through outside the checkout, and runs the selected ReleaseKit tool by its full package
`go tool releasekit`. It then uses `ci-deliver --prepared` with the mapped profile path. It passes `ci-deliver --prepared`, the mapped profile, and the expected
and expected physical module. physical module to the tool.
The `core-v2` profile demonstrates binary delivery; the other profiles are The `core-v2` profile demonstrates binary delivery; the other profiles are
page-only. Before enabling the workflow, configure each selected profile's page-only. Before enabling the workflow, configure each selected profile's
`delivery` object with `"forge": "gitea"`, its `base_url`, owner, and repository, `delivery` object with `"forge": "gitea"`, its `base_url`, owner, and repository,
@@ -126,9 +126,27 @@ source and each selected product's module-local release note, then commit the
fixture and run the source-selected tool without publishing: fixture and run the source-selected tool without publishing:
```sh ```sh
go tool releasekit check --config releasekit-root.json v1.2.3 (
go -C integrations/ckconfig tool releasekit check --config releasekit-ckconfig.json v1.2.3 cd . || exit $?
go -C integrations/core/v2 tool releasekit check --config releasekit-core-v2.json v2.1.0 releasekit_host_os=$(go env GOHOSTOS) || exit $?
releasekit_host_arch=$(go env GOHOSTARCH) || exit $?
export GOOS="$releasekit_host_os" GOARCH="$releasekit_host_arch"
go tool go.maximumdirect.net/releasekit/cmd/releasekit check --config releasekit-root.json v1.2.3
)
(
cd ./integrations/ckconfig || exit $?
releasekit_host_os=$(go env GOHOSTOS) || exit $?
releasekit_host_arch=$(go env GOHOSTARCH) || exit $?
export GOOS="$releasekit_host_os" GOARCH="$releasekit_host_arch"
go tool go.maximumdirect.net/releasekit/cmd/releasekit check --config releasekit-ckconfig.json v1.2.3
)
(
cd ./integrations/core/v2 || exit $?
releasekit_host_os=$(go env GOHOSTOS) || exit $?
releasekit_host_arch=$(go env GOHOSTARCH) || exit $?
export GOOS="$releasekit_host_os" GOARCH="$releasekit_host_arch"
go tool go.maximumdirect.net/releasekit/cmd/releasekit check --config releasekit-core-v2.json v2.1.0
)
``` ```
These commands use the repository-root profile paths even when invoked from a These commands use the repository-root profile paths even when invoked from a

View File

@@ -36,10 +36,13 @@ releasekit_module=go.maximumdirect.net/releasekit
cd "$module_path" cd "$module_path"
export GOWORK=off export GOWORK=off
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
releasekit_host_os=$(go env GOHOSTOS) || exit $?
releasekit_host_arch=$(go env GOHOSTARCH) || exit $?
export GOOS="$releasekit_host_os" GOARCH="$releasekit_host_arch"
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module" go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module"
go mod edit -modfile="$bootstrap_dir/releasekit.mod" -tool="$releasekit_module/cmd/releasekit" go mod edit -modfile="$bootstrap_dir/releasekit.mod" -tool="$releasekit_module/cmd/releasekit"
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -deps "$releasekit_module/cmd/releasekit" > /dev/null go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -deps "$releasekit_module/cmd/releasekit" > /dev/null
go tool -modfile="$bootstrap_dir/releasekit.mod" releasekit publish \ go tool -modfile="$bootstrap_dir/releasekit.mod" "$releasekit_module/cmd/releasekit" publish \
--repo "$repository_dir" \ --repo "$repository_dir" \
--config "$profile_path" \ --config "$profile_path" \
--expected-module "$module_dir" \ --expected-module "$module_dir" \

View File

@@ -52,10 +52,13 @@ steps:
releasekit_module=go.maximumdirect.net/releasekit releasekit_module=go.maximumdirect.net/releasekit
( (
cd "$${module_path}" cd "$${module_path}"
releasekit_host_os=$(go env GOHOSTOS) || exit $?
releasekit_host_arch=$(go env GOHOSTARCH) || exit $?
export GOOS="$${releasekit_host_os}" GOARCH="$${releasekit_host_arch}"
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}" go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit" go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit"
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null
go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \ go tool -modfile="$${bootstrap_dir}/releasekit.mod" "$${releasekit_module}/cmd/releasekit" ci-deliver \
--repo "$${CI_WORKSPACE}" \ --repo "$${CI_WORKSPACE}" \
--config "$${profile_path}" \ --config "$${profile_path}" \
--expected-module "$${module_dir}" \ --expected-module "$${module_dir}" \

View File

@@ -30,6 +30,9 @@ steps:
trap 'rm -rf "$${bootstrap_dir}"' EXIT trap 'rm -rf "$${bootstrap_dir}"' EXIT
export GOWORK=off export GOWORK=off
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
releasekit_host_os=$(go env GOHOSTOS) || exit $?
releasekit_host_arch=$(go env GOHOSTARCH) || exit $?
export GOOS="$${releasekit_host_os}" GOARCH="$${releasekit_host_arch}"
go build -mod=readonly -o "$${bootstrap_dir}/releasekit" ./cmd/releasekit go build -mod=readonly -o "$${bootstrap_dir}/releasekit" ./cmd/releasekit
"$${bootstrap_dir}/releasekit" ci-deliver \ "$${bootstrap_dir}/releasekit" ci-deliver \
--repo "$${CI_WORKSPACE}" \ --repo "$${CI_WORKSPACE}" \

View File

@@ -41,11 +41,14 @@ steps:
export GOWORK=off export GOWORK=off
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
releasekit_host_os=$(go env GOHOSTOS) || exit $?
releasekit_host_arch=$(go env GOHOSTARCH) || exit $?
export GOOS="$${releasekit_host_os}" GOARCH="$${releasekit_host_arch}"
releasekit_module=go.maximumdirect.net/releasekit releasekit_module=go.maximumdirect.net/releasekit
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}" go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit" go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit"
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null
go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \ go tool -modfile="$${bootstrap_dir}/releasekit.mod" "$${releasekit_module}/cmd/releasekit" ci-deliver \
--repo "$${CI_WORKSPACE}" \ --repo "$${CI_WORKSPACE}" \
--prepared \ --prepared \
--commit "$${expected_commit}" \ --commit "$${expected_commit}" \

View File

@@ -43,10 +43,16 @@ func TemporaryExecutable(path string) bool {
return err == nil return err == nil
} }
// RecoveryTool returns module-selected Go arguments for a later invocation. // RecoveryInvocation describes a module-selected Go tool invocation.
// An empty result means this module does not select ReleaseKit. API-only modules type RecoveryInvocation struct {
Directory string
Args []string
}
// RecoveryTool returns a module-selected Go invocation for later use.
// A nil result means this module does not select ReleaseKit. API-only modules
// receive a private module root that outlives launcher-owned bootstraps. // receive a private module root that outlives launcher-owned bootstraps.
func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, error) { func RecoveryTool(ctx context.Context, moduleDir, recordDir string) (*RecoveryInvocation, error) {
path := filepath.Join(moduleDir, "go.mod") path := filepath.Join(moduleDir, "go.mod")
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -58,7 +64,7 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, e
} }
for _, tool := range module.Tool { for _, tool := range module.Tool {
if tool.Path == releaseKitCommand { if tool.Path == releaseKitCommand {
return []string{"-C", moduleDir, "tool", "-modfile=", releaseKitCommand}, nil return &RecoveryInvocation{Directory: moduleDir, Args: []string{"tool", "-modfile=", releaseKitCommand}}, nil
} }
} }
// Ask Go about the selected module graph, including transitive dependencies. // Ask Go about the selected module graph, including transitive dependencies.
@@ -94,9 +100,11 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, e
// replacements relative to the directory that originally owned go.mod. // replacements relative to the directory that originally owned go.mod.
for _, replacement := range module.Replace { for _, replacement := range module.Replace {
if replacement.New.Version == "" && !filepath.IsAbs(replacement.New.Path) { if replacement.New.Version == "" && !filepath.IsAbs(replacement.New.Path) {
if err := module.AddReplace(replacement.Old.Path, replacement.Old.Version, filepath.Join(moduleDir, replacement.New.Path), ""); err != nil { // A local replacement ends in its path token. Edit that token on
return nil, err // the parsed directive so a wildcard does not absorb versioned ones.
} replacement.New.Path = filepath.Join(moduleDir, replacement.New.Path)
tokens := replacement.Syntax.Token
tokens[len(tokens)-1] = modfile.AutoQuote(replacement.New.Path)
} }
} }
// The original main module's source remains local if a dependency refers // The original main module's source remains local if a dependency refers
@@ -140,15 +148,31 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, e
return nil, err return nil, err
} }
// Prepare the private graph and sums before the later tool invocation. // Prepare the private graph and sums before the later tool invocation.
environment := map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"}
hostJSON, err := discover(ctx, directory, environment, "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH")
if err != nil {
return nil, fmt.Errorf("prepare recovery tool: %w", err)
}
var host struct {
GOHOSTOS string
GOHOSTARCH string
}
if err := json.Unmarshal(hostJSON, &host); err != nil {
return nil, fmt.Errorf("prepare recovery tool: decode host target: %w", err)
}
if host.GOHOSTOS == "" || host.GOHOSTARCH == "" {
return nil, fmt.Errorf("prepare recovery tool: missing host target")
}
environment["GOOS"], environment["GOARCH"] = host.GOHOSTOS, host.GOHOSTARCH
captured := &combinedWriter{mode: captureStatus} captured := &combinedWriter{mode: captureStatus}
err = process.Run(ctx, process.Invocation{ err = process.Run(ctx, process.Invocation{
Args: []string{"go", "list", "-mod=mod", "-modfile=", "-deps", releaseKitCommand}, Args: []string{"go", "list", "-mod=mod", "-modfile=", "-deps", releaseKitCommand},
Dir: directory, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"}, Dir: directory, Env: environment,
Stdout: captured, Stderr: captured, Stdout: captured, Stderr: captured,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare recovery tool: %w: %s", err, diagnosticText(captured.take(), captured.omitted())) return nil, fmt.Errorf("prepare recovery tool: %w: %s", err, diagnosticText(captured.take(), captured.omitted()))
} }
complete = true complete = true
return []string{"-C", directory, "tool", "-modfile=", releaseKitCommand}, nil return &RecoveryInvocation{Directory: directory, Args: []string{"tool", "-modfile=", releaseKitCommand}}, nil
} }

View File

@@ -6,6 +6,9 @@ if [ "$#" -ne 1 ]; then
exit 2 exit 2
fi fi
releasekit_host_os=$(GOTOOLCHAIN=local go env GOHOSTOS) || exit $?
releasekit_host_arch=$(GOTOOLCHAIN=local go env GOHOSTARCH) || exit $?
export GOOS="$releasekit_host_os" GOARCH="$releasekit_host_arch"
mkdir -p .releasekit/bin mkdir -p .releasekit/bin
GOTOOLCHAIN=local go build -o .releasekit/bin/releasekit ./cmd/releasekit GOTOOLCHAIN=local go build -o .releasekit/bin/releasekit ./cmd/releasekit
exec .releasekit/bin/releasekit publish "$1" exec .releasekit/bin/releasekit publish "$1"