Keep publication recovery usable after cold Go tool runs
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
"go.maximumdirect.net/releasekit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s
|
|||||||
request.PublicationRecordDir = invocation.RecordDir
|
request.PublicationRecordDir = invocation.RecordDir
|
||||||
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
|
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
|
||||||
result, err := releasekit.Publish(ctx, request)
|
result, err := releasekit.Publish(ctx, request)
|
||||||
reportPublicationRecord(result, err, stderr)
|
reportPublicationRecord(ctx, result, err, stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.TagObject, git.DisplayURL(result.Endpoint), candidateFailureText(err))
|
fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.TagObject, git.DisplayURL(result.Endpoint), candidateFailureText(err))
|
||||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||||
@@ -101,7 +102,7 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportPublicationRecord(result releasekit.PublicationResult, publicationErr error, stderr io.Writer) {
|
func reportPublicationRecord(ctx context.Context, result releasekit.PublicationResult, publicationErr error, stderr io.Writer) {
|
||||||
if result.RecordError != nil {
|
if result.RecordError != nil {
|
||||||
fmt.Fprintf(stderr, "publication record warning: %s\n", git.DisplayError(result.RecordError))
|
fmt.Fprintf(stderr, "publication record warning: %s\n", git.DisplayError(result.RecordError))
|
||||||
}
|
}
|
||||||
@@ -112,14 +113,39 @@ func reportPublicationRecord(result releasekit.PublicationResult, publicationErr
|
|||||||
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
|
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
command, err := publicationRecoveryCommand(result.RecordPath)
|
command, err := selectedPublicationRecoveryCommand(ctx, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "recovery command unavailable: %s\n", git.DisplayError(err))
|
fmt.Fprintf(stderr, "recovery command unavailable: %s\n", git.DisplayError(err))
|
||||||
fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.")
|
fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stderr, "recovery command: %s\n", git.DisplayDiagnostic(command))
|
fmt.Fprintf(stderr, "recovery command: %s\n", git.DisplayDiagnostic(command))
|
||||||
fmt.Fprintln(stderr, "If this executable is unavailable, use your selected supported ReleaseKit CLI with the same record.")
|
fmt.Fprintln(stderr, "If this entry point is unavailable, use your selected supported ReleaseKit CLI with the same record.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedPublicationRecoveryCommand(ctx context.Context, result releasekit.PublicationResult) (string, error) {
|
||||||
|
if result.Evidence.ModuleDir != "" {
|
||||||
|
common, err := git.CommonDirectory(ctx, result.RepositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
args, err := gotool.RecoveryTool(ctx, result.Evidence.ModuleDir, filepath.Join(common, "releasekit", "publication-recovery"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(args) > 0 {
|
||||||
|
command := "env GOWORK=off GOTOOLCHAIN=local go"
|
||||||
|
for _, arg := range args {
|
||||||
|
command += " " + shellQuote(git.DisplayDiagnostic(arg))
|
||||||
|
}
|
||||||
|
path, err := filepath.Abs(git.DisplayDiagnostic(result.RecordPath))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return command + " reconcile --record " + shellQuote(path), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return publicationRecoveryCommand(result.RecordPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func publicationOffersRecovery(phase releasekit.PublicationPhase) bool {
|
func publicationOffersRecovery(phase releasekit.PublicationPhase) bool {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -15,6 +17,152 @@ import (
|
|||||||
"go.maximumdirect.net/releasekit/internal/publish"
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("fixture observes the cold executable through /proc")
|
||||||
|
}
|
||||||
|
for _, registered := range []bool{true, false} {
|
||||||
|
t.Run(fmt.Sprintf("registered=%t", registered), func(t *testing.T) {
|
||||||
|
repo, remote := newSimplePublishRepository(t, false)
|
||||||
|
source := repositoryRoot(t)
|
||||||
|
// A distinct replacement identity gives this tool a fresh link action
|
||||||
|
// while retaining the shared compilation cache for its dependencies.
|
||||||
|
selectedSource := filepath.Join(t.TempDir(), "ReleaseKit source")
|
||||||
|
if err := os.Symlink(source, selectedSource); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !registered {
|
||||||
|
relative, err := filepath.Rel(repo, selectedSource)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selectedSource = relative
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
if registered {
|
||||||
|
module += "\ntool go.maximumdirect.net/releasekit/cmd/releasekit\n"
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repo, "go.mod", module)
|
||||||
|
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repo, "go.sum", string(sums))
|
||||||
|
if !registered {
|
||||||
|
for _, name := range []string{"release.sh", "select-module.sh"} {
|
||||||
|
data, err := os.ReadFile(filepath.Join(source, "examples", "multi-module", name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, name), data, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repo, "release-modules.tsv", "root\tv*\t.\treleasekit.json\n")
|
||||||
|
}
|
||||||
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
||||||
|
if registered {
|
||||||
|
prepare := exec.Command(goBinary, "list", "-mod=mod", "-deps", "go.maximumdirect.net/releasekit/cmd/releasekit")
|
||||||
|
prepare.Dir = repo
|
||||||
|
if out, err := prepare.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("prepare tool: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cliGitRun(t, repo, "add", ".")
|
||||||
|
cliGitRun(t, repo, "commit", "-m", "Select release tool")
|
||||||
|
cliGitRun(t, repo, "push", "origin", "main")
|
||||||
|
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
executed := filepath.Join(t.TempDir(), "executed-tool")
|
||||||
|
wrapper := "#!/bin/sh\nparent=$(readlink \"/proc/$PPID/exe\")\ncase \"$parent\" in */exe/releasekit) printf '%s' \"$parent\" > " + shellQuote(executed) + ";; esac\nexec " + shellQuote(realGit) + " \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
var launch *exec.Cmd
|
||||||
|
if registered {
|
||||||
|
launch = exec.Command(goBinary, "tool", "releasekit", "publish", "--repo", repo, "v1.2.3")
|
||||||
|
} else {
|
||||||
|
launch = exec.Command(filepath.Join(repo, "release.sh"), "root", "v1.2.3")
|
||||||
|
}
|
||||||
|
launch.Dir = repo
|
||||||
|
out, err := launch.CombinedOutput()
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
||||||
|
t.Fatalf("publication: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
text := string(out)
|
||||||
|
launchedPath, err := os.ReadFile(executed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("did not exercise a cold temporary executable: %v: %s", err, text)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(string(launchedPath)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("cold executable still exists: %s: %v", launchedPath, err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(repo))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
|
||||||
|
t.Fatalf("launcher bootstrap survived: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
command := outputLineValue(text, "recovery command: ")
|
||||||
|
if !strings.Contains(command, "go ") || strings.Contains(command, "/exe/releasekit") {
|
||||||
|
t.Fatalf("unstable handoff: %s", command)
|
||||||
|
}
|
||||||
|
if !registered && !strings.Contains(command, "publication-recovery") {
|
||||||
|
t.Fatalf("API-only handoff lacks durable metadata: %s", command)
|
||||||
|
}
|
||||||
|
// Execute from another directory after the original driver and the
|
||||||
|
// API-only launcher's temporary files are gone.
|
||||||
|
evidence, err := publish.LoadEvidence(outputLineValue(text, "publication record saved: "))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
evidence.Endpoint = filepath.Join(t.TempDir(), "unavailable.git")
|
||||||
|
unknownRecord, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, scenario := range []struct {
|
||||||
|
record string
|
||||||
|
status int
|
||||||
|
}{{"", 1}, {filepath.Join(t.TempDir(), "missing.json"), 2}, {unknownRecord, 3}, {"", 0}} {
|
||||||
|
if scenario.status == 0 {
|
||||||
|
if err := os.Remove(filepath.Join(remote, "hooks", "pre-receive")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cliGitRun(t, repo, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
}
|
||||||
|
selected := command
|
||||||
|
if scenario.record != "" {
|
||||||
|
selected += " --record " + shellQuote(scenario.record)
|
||||||
|
}
|
||||||
|
recovery := exec.Command("sh", "-c", selected)
|
||||||
|
recovery.Dir = t.TempDir()
|
||||||
|
recovered, err := recovery.CombinedOutput()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if scenario.status == 1 && !strings.Contains(string(recovered), "state=not_published") {
|
||||||
|
t.Fatalf("wrong observation: %s", recovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
|
||||||
|
t.Fatalf("handoff modified candidate: %s", status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) {
|
func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) {
|
||||||
repository, _ := newSimplePublishRepository(t, true)
|
repository, _ := newSimplePublishRepository(t, true)
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func TestRecoveryDisplaysRedactCredentialPathsAndMetadata(t *testing.T) {
|
|||||||
t.Fatalf("private metadata changed: %#v, %v", loaded, err)
|
t.Fatalf("private metadata changed: %#v, %v", loaded, err)
|
||||||
}
|
}
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
reportPublicationRecord(releasekit.PublicationResult{RecordPath: raw, Phase: releasekit.PublicationPush}, fmt.Errorf("push failed"), &out)
|
reportPublicationRecord(t.Context(), releasekit.PublicationResult{RecordPath: raw, Phase: releasekit.PublicationPush}, fmt.Errorf("push failed"), &out)
|
||||||
if strings.Contains(out.String(), secret) {
|
if strings.Contains(out.String(), secret) {
|
||||||
t.Fatalf("handoff leaked credentials: %s", &out)
|
t.Fatalf("handoff leaked credentials: %s", &out)
|
||||||
}
|
}
|
||||||
|
|||||||
12
docs/cli.md
12
docs/cli.md
@@ -144,10 +144,14 @@ relative override is resolved from the source repository root. Standard error
|
|||||||
reports the absolute saved record path or a recording warning separately from
|
reports the absolute saved record path or a recording warning separately from
|
||||||
the publication result. Recording failure does not change publication status.
|
the publication result. Recording failure does not change publication status.
|
||||||
When publication fails and a record was saved, the CLI also prints a copyable
|
When publication fails and a record was saved, the CLI also prints a copyable
|
||||||
`reconcile --record` command for that exact record. The command uses the running
|
`reconcile --record` command for that exact record. The command selects the
|
||||||
executable's absolute path, so it remains usable when the executable is not on
|
consumer module's registered Go tool when available. API-only consumers receive
|
||||||
`PATH`; if that executable is no longer available, use another supported
|
a Go-tool invocation with durable private module files; see
|
||||||
ReleaseKit CLI.
|
[recovery storage](consumers/publish.md#retain-and-reconcile-publication-evidence).
|
||||||
|
Consumers without a module-selected ReleaseKit dependency use the running
|
||||||
|
executable's absolute path. Each entry point preserves the recovery exit status.
|
||||||
|
If the selected entry point becomes unavailable, use another supported ReleaseKit
|
||||||
|
CLI with the saved record.
|
||||||
|
|
||||||
The command returns status 0 for confirmed publication, 1 for a known
|
The command returns status 0 for confirmed publication, 1 for a known
|
||||||
publication failure or conflict, 2 for usage, profile, or invalid-version
|
publication failure or conflict, 2 for usage, profile, or invalid-version
|
||||||
|
|||||||
@@ -135,6 +135,18 @@ directories privately and creates record files with owner-only permissions;
|
|||||||
existing directory permissions are left as they are. The caller owns storage
|
existing directory permissions are left as they are. The caller owns storage
|
||||||
retention and removal.
|
retention and removal.
|
||||||
|
|
||||||
|
The CLI prepares a recovery entry point when a failed publication has a saved
|
||||||
|
record. Registered-tool consumers use their selected module's Go tool directly.
|
||||||
|
For API-only consumers, ReleaseKit retains alternate module and checksum files
|
||||||
|
under `<Git common directory>/releasekit/publication-recovery/`, with private
|
||||||
|
directory and file permissions. These files add the CLI tool declaration and
|
||||||
|
prepare its module graph without changing consumer source. They survive a
|
||||||
|
launcher's temporary module-file cleanup. The printed command runs Go from the
|
||||||
|
selected module directory; retain that checkout and the private recovery files
|
||||||
|
while the entry point is needed. Remove the private recovery directories when
|
||||||
|
their recovery value expires. Another supported CLI can read the saved record
|
||||||
|
independently of these launch files.
|
||||||
|
|
||||||
The saved endpoint is retained exactly for later observation and may include
|
The saved endpoint is retained exactly for later observation and may include
|
||||||
credentials. Treat records, `PublicationEvidence`, and `PublicationResult` as
|
credentials. Treat records, `PublicationEvidence`, and `PublicationResult` as
|
||||||
sensitive. Store overrides in a location with suitable access controls, avoid
|
sensitive. Store overrides in a location with suitable access controls, avoid
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ Standalone and delivery builds create their own owners. The package uses
|
|||||||
`internal/process` for cancellation, the working directory, output, and wrapped
|
`internal/process` for cancellation, the working directory, output, and wrapped
|
||||||
errors.
|
errors.
|
||||||
|
|
||||||
|
For CLI publication recovery, the package selects a registered module tool or
|
||||||
|
prepares durable alternate module files for an API-only consumer. Go's module
|
||||||
|
graph query includes transitive dependencies; preparation retains local module
|
||||||
|
selection and uses the original module directory for relative replacements.
|
||||||
|
The CLI formats the later invocation and falls back to its native executable
|
||||||
|
for consumers without a selected ReleaseKit module. The
|
||||||
|
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence)
|
||||||
|
owns recovery-file placement and retention.
|
||||||
|
|
||||||
## Command output ownership
|
## Command output ownership
|
||||||
|
|
||||||
`Tool.Run` executes a status-only command. It forwards combined stdout and
|
`Tool.Run` executes a status-only command. It forwards combined stdout and
|
||||||
|
|||||||
@@ -249,6 +249,13 @@ remain open and are preserved rather than duplicated here.
|
|||||||
|
|
||||||
## R07: Make the recovery handoff survive a cold Go-tool invocation — P2
|
## R07: Make the recovery handoff survive a cold Go-tool invocation — P2
|
||||||
|
|
||||||
|
**Completed:** CLI recovery prefers the selected module's Go tool. API-only
|
||||||
|
consumers receive privately retained alternate module/checksum files under Git
|
||||||
|
metadata; native consumers retain the executable-path fallback. Assembled tests
|
||||||
|
passed after cold executables disappeared and launcher files were removed,
|
||||||
|
covering both adoption styles, relative replacements, clean candidates, and
|
||||||
|
recovery exit statuses 0–3. CLI, consumer, and internal guidance is updated.
|
||||||
|
|
||||||
**Relevant code:** `cmd/releasekit/main.go:134` (`publicationRecoveryCommand`);
|
**Relevant code:** `cmd/releasekit/main.go:134` (`publicationRecoveryCommand`);
|
||||||
`examples/multi-module/release.sh`; `docs/consumers/adoption.md`'s module-selected
|
`examples/multi-module/release.sh`; `docs/consumers/adoption.md`'s module-selected
|
||||||
Go-tool workflow; `cmd/releasekit/publication_handoff_test.go`.
|
Go-tool workflow; `cmd/releasekit/publication_handoff_test.go`.
|
||||||
|
|||||||
108
internal/gotool/recovery.go
Normal file
108
internal/gotool/recovery.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package gotool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/mod/modfile"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
|
)
|
||||||
|
|
||||||
|
const releaseKitModule = "go.maximumdirect.net/releasekit"
|
||||||
|
const releaseKitCommand = releaseKitModule + "/cmd/releasekit"
|
||||||
|
|
||||||
|
// RecoveryTool returns module-selected Go arguments for a later invocation.
|
||||||
|
// An empty result means this module does not select ReleaseKit. API-only modules
|
||||||
|
// receive private alternate module files that outlive launcher-owned bootstraps.
|
||||||
|
func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, error) {
|
||||||
|
path := filepath.Join(moduleDir, "go.mod")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
module, err := modfile.Parse(path, data, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, tool := range module.Tool {
|
||||||
|
if tool.Path == releaseKitCommand {
|
||||||
|
return []string{"-C", moduleDir, "tool", "-modfile=", releaseKitCommand}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ask Go about the selected module graph, including transitive dependencies.
|
||||||
|
// This reads real module metadata, independently of a launcher's -modfile.
|
||||||
|
var selection bytes.Buffer
|
||||||
|
err = process.Run(ctx, process.Invocation{
|
||||||
|
Args: []string{"go", "list", "-e", "-json", "-mod=readonly", "-modfile=", "-m", releaseKitModule},
|
||||||
|
Dir: moduleDir, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"},
|
||||||
|
Stdout: &selection, Stderr: io.Discard,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("identify recovery tool selection: %w", err)
|
||||||
|
}
|
||||||
|
var selected struct {
|
||||||
|
Main bool
|
||||||
|
Version string
|
||||||
|
Error *struct{ Err string }
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(selection.Bytes(), &selected); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !selected.Main && selected.Version == "" {
|
||||||
|
// A source-built CLI also supports consumers without a module dependency.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if selected.Error != nil {
|
||||||
|
return nil, fmt.Errorf("identify recovery tool selection: %s", selected.Error.Err)
|
||||||
|
}
|
||||||
|
if err := module.AddTool(releaseKitCommand); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err = module.Format()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sums, err := os.ReadFile(filepath.Join(moduleDir, "go.sum"))
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(recordDir, 0700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
directory, err := os.MkdirTemp(recordDir, "recovery-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create durable recovery module directory: %w", err)
|
||||||
|
}
|
||||||
|
complete := false
|
||||||
|
defer func() {
|
||||||
|
if !complete {
|
||||||
|
_ = os.RemoveAll(directory)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
alternate := filepath.Join(directory, "releasekit.mod")
|
||||||
|
if err := os.WriteFile(alternate, data, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "releasekit.sum"), sums, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// go tool accepts an alternate module file but has no -mod flag. Prepare
|
||||||
|
// the private graph and sums before the later read-only tool invocation.
|
||||||
|
captured := &combinedWriter{mode: captureStatus}
|
||||||
|
err = process.Run(ctx, process.Invocation{
|
||||||
|
Args: []string{"go", "list", "-mod=mod", "-modfile=" + alternate, "-deps", releaseKitCommand},
|
||||||
|
Dir: moduleDir, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"},
|
||||||
|
Stdout: captured, Stderr: captured,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare recovery tool: %w: %s", err, diagnosticText(captured.take(), captured.omitted()))
|
||||||
|
}
|
||||||
|
complete = true
|
||||||
|
return []string{"-C", moduleDir, "tool", "-modfile=" + alternate, releaseKitCommand}, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user