Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fab30337a | |||
| e736166a39 | |||
| a2d75c462c | |||
| 31f9e48219 | |||
| 473670185c | |||
| c8fa165dc9 | |||
| 2f95753d9c | |||
| 9dc87ae996 | |||
| d9c97fd915 | |||
| 1d40fb7ac0 | |||
| 03ee83e8a5 | |||
| 9cb48a70b7 | |||
| 2b23574ece | |||
| 76676ee5a6 | |||
| 23b3a3d340 | |||
| ef47356541 | |||
| 0666b68ff6 | |||
| 6f2e62bea3 | |||
| aa57fee4de | |||
| 92c95ef031 | |||
| 595745c7b1 | |||
| 9c02ed769b | |||
| 94aedacf05 | |||
| 7e6e0cc7d1 | |||
| aecab00ff6 | |||
| a1dc705be7 | |||
| b5b3f09599 | |||
| 6f7e43b097 | |||
| 91f25c452c | |||
| 083bc83331 | |||
| 5e6db4de76 | |||
| b345bc0ec4 | |||
| 3d7a691338 | |||
| 3754242969 | |||
| 9545bb0bfc | |||
| 620c832477 | |||
| 812c24de9e | |||
| 1b39e71e81 | |||
| d884ad731d |
5
build.go
5
build.go
@@ -145,10 +145,7 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
|
|||||||
func internalBuildResult(result BuildResult) *internalbuild.Result {
|
func internalBuildResult(result BuildResult) *internalbuild.Result {
|
||||||
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
|
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
|
||||||
settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.Targets)
|
settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.Targets)
|
||||||
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler}
|
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, OutputDir: result.OutputDir, Compiler: result.Compiler}
|
||||||
for _, target := range settings.Targets {
|
|
||||||
converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
|
|
||||||
}
|
|
||||||
for _, artifact := range result.Artifacts {
|
for _, artifact := range result.Artifacts {
|
||||||
converted.Artifacts = append(converted.Artifacts, internalbuild.Artifact{Path: artifact.Path, Filename: artifact.Filename, Target: internalbuild.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256})
|
converted.Artifacts = append(converted.Artifacts, internalbuild.Artifact{Path: artifact.Path, Filename: artifact.Filename, Target: internalbuild.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,41 @@ import (
|
|||||||
releasekit "go.maximumdirect.net/releasekit"
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestBuildReplacementDerivedSourceIsNotEligibleForDelivery(t *testing.T) {
|
||||||
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
|
original := buildGit(t, repository, "rev-parse", "HEAD")
|
||||||
|
mainPath := filepath.Join(repository, "cmd/app/main.go")
|
||||||
|
main, err := os.ReadFile(mainPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeBuildFixture(t, repository, "cmd/app/main.go", string(main)+"\n// replacement source\n")
|
||||||
|
buildGit(t, repository, "add", "cmd/app/main.go")
|
||||||
|
buildGit(t, repository, "commit", "-m", "Replacement source")
|
||||||
|
replacement := buildGit(t, repository, "rev-parse", "HEAD")
|
||||||
|
buildGit(t, repository, "reset", "--hard", original)
|
||||||
|
buildGit(t, repository, "replace", original, replacement)
|
||||||
|
buildGit(t, repository, "reset", "--hard", original)
|
||||||
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||||
|
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
|
||||||
|
})
|
||||||
|
if err != nil || result.SourceVerified || len(result.Artifacts) != 1 || result.CandidateCommit != original {
|
||||||
|
t.Fatalf("replacement build = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
buildGit(t, repository, "replace", "-d", original)
|
||||||
|
buildGit(t, repository, "reset", "--hard", original)
|
||||||
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
|
defer server.Close()
|
||||||
|
delivery, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||||
|
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &result,
|
||||||
|
})
|
||||||
|
if err == nil || delivery.State != releasekit.DeliveryNotAttempted || remote.snapshot().writes != 0 {
|
||||||
|
t.Fatalf("delivery of replacement build = %#v, error = %v", delivery, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
||||||
spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}}
|
spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}}
|
||||||
invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
@@ -486,6 +521,61 @@ func TestBuildReportsMissingMainPackage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildRetainsOnlyHashedArtifactsAfterReadFailure(t *testing.T) {
|
||||||
|
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
||||||
|
realGo, err := exec.LookPath("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
count := filepath.Join(t.TempDir(), "count")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
if [ "$1" != build ]; then exec "$RELEASEKIT_REAL_GO" "$@"; fi
|
||||||
|
output=
|
||||||
|
previous=
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$previous" = -o ]; then output=$arg; fi
|
||||||
|
previous=$arg
|
||||||
|
done
|
||||||
|
"$RELEASEKIT_REAL_GO" "$@" || exit $?
|
||||||
|
if [ -f "$RELEASEKIT_BUILD_COUNT" ]; then
|
||||||
|
rm "$output"
|
||||||
|
mkdir "$output"
|
||||||
|
else
|
||||||
|
: > "$RELEASEKIT_BUILD_COUNT"
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("RELEASEKIT_REAL_GO", realGo)
|
||||||
|
t.Setenv("RELEASEKIT_BUILD_COUNT", count)
|
||||||
|
secondArch := "arm64"
|
||||||
|
if runtime.GOARCH == secondArch {
|
||||||
|
secondArch = "amd64"
|
||||||
|
}
|
||||||
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||||
|
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}, {OS: runtime.GOOS, Arch: secondArch}}},
|
||||||
|
})
|
||||||
|
var pathErr *os.PathError
|
||||||
|
if !errors.As(err, &pathErr) || !strings.Contains(err.Error(), "hash build output") || len(result.Artifacts) != 1 || len(result.Targets) != 2 || result.MainPackage != "./cmd/app" || result.BinaryName != "fixture" || result.CandidateCommit == "" || result.SourceVerified {
|
||||||
|
t.Fatalf("partial build = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
contents, readErr := os.ReadFile(result.Artifacts[0].Path)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(contents)
|
||||||
|
if result.Artifacts[0].SHA256 != hex.EncodeToString(digest[:]) {
|
||||||
|
t.Fatalf("completed artifact digest = %q", result.Artifacts[0].SHA256)
|
||||||
|
}
|
||||||
|
if info, statErr := os.Stat(pathErr.Path); statErr != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("failed output was not retained: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) {
|
func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) {
|
||||||
repository := newBuildConsumer(t, `package main
|
repository := newBuildConsumer(t, `package main
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ type BuildReceipt struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
|
func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
|
||||||
|
return writeBuildReceipt(outputDir, receipt, os.Remove)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBuildReceipt(outputDir string, receipt BuildReceipt, remove func(string) error) (resultPath string, resultErr error) {
|
||||||
resolvedDir, err := filepath.Abs(outputDir)
|
resolvedDir, err := filepath.Abs(outputDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve build receipt directory: %w", err)
|
return "", fmt.Errorf("resolve build receipt directory: %w", err)
|
||||||
@@ -67,7 +71,11 @@ func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
|
|||||||
return "", fmt.Errorf("create temporary build receipt: %w", err)
|
return "", fmt.Errorf("create temporary build receipt: %w", err)
|
||||||
}
|
}
|
||||||
temporaryPath := temporary.Name()
|
temporaryPath := temporary.Name()
|
||||||
defer os.Remove(temporaryPath)
|
defer func() {
|
||||||
|
if err := remove(temporaryPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary build receipt %q: %w", temporaryPath, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
if _, err := temporary.Write(contents); err != nil {
|
if _, err := temporary.Write(contents); err != nil {
|
||||||
_ = temporary.Close()
|
_ = temporary.Close()
|
||||||
return "", fmt.Errorf("write build receipt: %w", err)
|
return "", fmt.Errorf("write build receipt: %w", err)
|
||||||
@@ -182,32 +190,15 @@ func validateBuildReceipt(receipt BuildReceipt) error {
|
|||||||
if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) {
|
if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) {
|
||||||
return errors.New("build receipt target and artifact counts do not match")
|
return errors.New("build receipt target and artifact counts do not match")
|
||||||
}
|
}
|
||||||
seenTargets := make(map[ReceiptTarget]bool, len(receipt.Targets))
|
declarations := make([]artifact.Declaration, 0, len(receipt.Artifacts))
|
||||||
seenArtifacts := make(map[ReceiptTarget]bool, len(receipt.Artifacts))
|
for _, item := range receipt.Artifacts {
|
||||||
targets := make([]artifact.Target, 0, len(receipt.Targets))
|
declarations = append(declarations, artifact.Declaration{Target: artifact.Target{OS: item.Target.OS, Arch: item.Target.Arch}, Filename: item.Filename})
|
||||||
for _, target := range receipt.Targets {
|
|
||||||
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
|
||||||
return fmt.Errorf("build receipt has unsafe target %q/%q", target.OS, target.Arch)
|
|
||||||
}
|
}
|
||||||
if seenTargets[target] {
|
if err := artifact.ValidateDeclarations(receipt.BinaryName, receipt.Version, parseStoredTargets(receipt.Targets), declarations); err != nil {
|
||||||
return fmt.Errorf("build receipt repeats target %s/%s", target.OS, target.Arch)
|
return fmt.Errorf("build receipt: %w", err)
|
||||||
}
|
|
||||||
seenTargets[target] = true
|
|
||||||
targets = append(targets, artifact.Target{OS: target.OS, Arch: target.Arch})
|
|
||||||
}
|
|
||||||
declarations := artifact.ExpectedBuild(receipt.BinaryName, receipt.Version, targets)
|
|
||||||
wantByTarget := make(map[ReceiptTarget]string, len(declarations))
|
|
||||||
for _, declaration := range declarations {
|
|
||||||
target := ReceiptTarget{OS: declaration.Target.OS, Arch: declaration.Target.Arch}
|
|
||||||
wantByTarget[target] = declaration.Filename
|
|
||||||
}
|
}
|
||||||
for _, artifact := range receipt.Artifacts {
|
for _, artifact := range receipt.Artifacts {
|
||||||
if !seenTargets[artifact.Target] || seenArtifacts[artifact.Target] {
|
if filepath.Base(artifact.Filename) != artifact.Filename {
|
||||||
return fmt.Errorf("build receipt has an unexpected artifact target %s/%s", artifact.Target.OS, artifact.Target.Arch)
|
|
||||||
}
|
|
||||||
seenArtifacts[artifact.Target] = true
|
|
||||||
wantName := wantByTarget[artifact.Target]
|
|
||||||
if artifact.Filename != wantName || filepath.Base(artifact.Filename) != artifact.Filename {
|
|
||||||
return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename)
|
return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename)
|
||||||
}
|
}
|
||||||
wantPath := filepath.Join(receipt.OutputDir, artifact.Filename)
|
wantPath := filepath.Join(receipt.OutputDir, artifact.Filename)
|
||||||
|
|||||||
@@ -4,12 +4,68 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestWriteBuildReceiptReportsTemporaryCleanup(t *testing.T) {
|
||||||
|
for _, mode := range []string{"renamed", "cleanup failure", "install and cleanup failure"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
repository := t.TempDir()
|
||||||
|
output := t.TempDir()
|
||||||
|
name := "app-v1.2.3-linux-amd64"
|
||||||
|
receipt := BuildReceipt{
|
||||||
|
SchemaVersion: BuildReceiptSchemaVersion, RepositoryDir: repository, ModuleDir: repository,
|
||||||
|
ModulePath: "example.com/receipt", Tag: "v1.2.3", CandidateCommit: strings.Repeat("a", 40),
|
||||||
|
Version: "v1.2.3", MainPackage: ".", BinaryName: "app", Compiler: "go fixture",
|
||||||
|
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
|
||||||
|
Artifacts: []ReceiptArtifact{{Path: filepath.Join(output, name), Filename: name, Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: strings.Repeat("a", 64)}},
|
||||||
|
}
|
||||||
|
if mode == "install and cleanup failure" {
|
||||||
|
blocked := filepath.Join(output, BuildReceiptName)
|
||||||
|
if err := os.Mkdir(blocked, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(blocked, "keep"), nil, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanupErr := errors.New("cleanup failed")
|
||||||
|
var temporaryPath string
|
||||||
|
remove := func(path string) error {
|
||||||
|
temporaryPath = path
|
||||||
|
err := os.Remove(path)
|
||||||
|
if mode == "renamed" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cleanupErr
|
||||||
|
}
|
||||||
|
path, err := writeBuildReceipt(output, receipt, remove)
|
||||||
|
if mode == "renamed" {
|
||||||
|
if err != nil || path != filepath.Join(output, BuildReceiptName) {
|
||||||
|
t.Fatalf("renamed receipt = %q, %v", path, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !errors.Is(err, cleanupErr) || temporaryPath == "" || !strings.Contains(err.Error(), temporaryPath) {
|
||||||
|
t.Fatalf("cleanup path = %q, error = %v", temporaryPath, err)
|
||||||
|
}
|
||||||
|
if mode == "cleanup failure" && path != filepath.Join(output, BuildReceiptName) {
|
||||||
|
t.Fatalf("installed receipt path = %q", path)
|
||||||
|
}
|
||||||
|
if mode == "install and cleanup failure" {
|
||||||
|
var pathErr *os.PathError
|
||||||
|
if !errors.As(err, &pathErr) {
|
||||||
|
t.Fatalf("install cause missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) {
|
func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version())
|
fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version())
|
||||||
return 0
|
return 0
|
||||||
case cli.CheckAction, cli.PublishAction:
|
case cli.CheckAction, cli.PublishAction:
|
||||||
|
return runCheckOrPublish(ctx, invocation, stdout, stderr)
|
||||||
|
case cli.BuildAction:
|
||||||
|
return runBuild(ctx, invocation, stdout, stderr)
|
||||||
|
case cli.DeliveryAction:
|
||||||
|
return runDelivery(ctx, invocation, stdout, stderr)
|
||||||
|
case cli.CIDeliveryAction:
|
||||||
|
return runCIDelivery(ctx, invocation, stdout, stderr)
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
||||||
@@ -60,7 +72,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
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)
|
||||||
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, 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) {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -82,7 +94,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "local candidate module %s version %s tag %s at %s is valid\n", candidate.ModulePath, candidate.Version, candidate.Tag, candidate.Commit)
|
fmt.Fprintf(stdout, "local candidate module %s version %s tag %s at %s is valid\n", candidate.ModulePath, candidate.Version, candidate.Tag, candidate.Commit)
|
||||||
return 0
|
return 0
|
||||||
case cli.BuildAction:
|
}
|
||||||
|
|
||||||
|
func runBuild(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
||||||
@@ -141,12 +155,6 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
|
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
|
||||||
return 0
|
return 0
|
||||||
case cli.DeliveryAction:
|
|
||||||
return runDelivery(ctx, invocation, stdout, stderr)
|
|
||||||
case cli.CIDeliveryAction:
|
|
||||||
return runCIDelivery(ctx, invocation, stdout, stderr)
|
|
||||||
}
|
|
||||||
return 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
|
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
|
||||||
@@ -426,9 +434,26 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
|||||||
Output: stderr,
|
Output: stderr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "delivery state=%s module=%s module-dir=%s version=%s tag=%s commit=%s url=%s: %v\n", result.State, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.ReleaseURL, err)
|
return reportDeliveryFailure(stderr, result, err)
|
||||||
writeDeliveryAssets(stderr, result)
|
}
|
||||||
writeDeliveryRecovery(stderr, result)
|
fmt.Fprintf(stdout, "delivered module %s version %s tag %s at %s (state %s)\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.State)
|
||||||
|
if result.ReleaseURL != "" {
|
||||||
|
fmt.Fprintf(stdout, "release: %s\n", result.ReleaseURL)
|
||||||
|
}
|
||||||
|
writeDeliveryAssets(stdout, result)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportDeliveryFailure(output io.Writer, result releasekit.DeliveryResult, err error) int {
|
||||||
|
if result.State == releasekit.DeliveryComplete {
|
||||||
|
fmt.Fprintln(output, "hosted delivery completed; local cleanup failed")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(output, "delivery state=%s module=%s module-dir=%s version=%s tag=%s commit=%s url=%s: %v\n", result.State, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.ReleaseURL, err)
|
||||||
|
writeDeliveryAssets(output, result)
|
||||||
|
writeDeliveryRecovery(output, result)
|
||||||
|
if result.State == releasekit.DeliveryComplete {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -440,13 +465,6 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
|||||||
return 3
|
return 3
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
}
|
|
||||||
fmt.Fprintf(stdout, "delivered module %s version %s tag %s at %s (state %s)\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.State)
|
|
||||||
if result.ReleaseURL != "" {
|
|
||||||
fmt.Fprintf(stdout, "release: %s\n", result.ReleaseURL)
|
|
||||||
}
|
|
||||||
writeDeliveryAssets(stdout, result)
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deliveryPreflightExitStatus(err error) int {
|
func deliveryPreflightExitStatus(err error) int {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -36,6 +37,29 @@ func TestVersionCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCIDeliverRedactsRemoteCredentialsOnPreparationFailure(t *testing.T) {
|
||||||
|
t.Setenv("NO_PROXY", "*")
|
||||||
|
t.Setenv("no_proxy", "*")
|
||||||
|
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||||
|
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
remote := strings.Replace(server.URL, "http://", "http://review-user:review-password@", 1) + "/repo.git"
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(t.Context(), []string{"ci-deliver", "--repo", repository, "--remote", remote, "--commit", commit, "v1.2.3"}, &stdout, &stderr)
|
||||||
|
if status != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "prepare tagged checkout") || !strings.Contains(stderr.String(), "repo.git") {
|
||||||
|
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(stderr.String(), "review-user") || strings.Contains(stderr.String(), "review-password") {
|
||||||
|
t.Fatalf("CLI preparation diagnostic exposes credentials: %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishCommandReportsUnknownForPeeledOnlyRecovery(t *testing.T) {
|
func TestPublishCommandReportsUnknownForPeeledOnlyRecovery(t *testing.T) {
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
|
writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
@@ -403,6 +427,30 @@ func TestCandidateFailurePresentationRetainsCallbackContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishCommandRedactsCredentialsInEndpointMismatch(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
|
settings := cli.Settings{Product: "Example Product", Remote: "origin", ExpectedPushURL: "https://expected-token@example.invalid/project.git"}
|
||||||
|
profile, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
|
||||||
|
cliGitRun(t, repository, "add", ".")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "configure release")
|
||||||
|
cliGitRun(t, repository, "remote", "add", "origin", "https://actual-user:actual-secret@example.invalid/project.git")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(context.Background(), []string{"publish", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||||
|
if status != 1 || !strings.Contains(stderr.String(), "unexpected publication push URL") || !strings.Contains(stderr.String(), "example.invalid/project.git") {
|
||||||
|
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"expected-token", "actual-user", "actual-secret"} {
|
||||||
|
if strings.Contains(stderr.String(), secret) || strings.Contains(stdout.String(), secret) {
|
||||||
|
t.Fatalf("CLI output exposes %q: %q %q", secret, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
|
func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
|
||||||
raceSelected := racePrerequisitesAvailable()
|
raceSelected := racePrerequisitesAvailable()
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
@@ -564,6 +612,65 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildCommandReportsOnlyHashedArtifactsAfterReadFailure(t *testing.T) {
|
||||||
|
repository := newCLIBuildRepository(t, "package main\nfunc main() {}\n")
|
||||||
|
secondArch := "arm64"
|
||||||
|
if runtime.GOARCH == secondArch {
|
||||||
|
secondArch = "amd64"
|
||||||
|
}
|
||||||
|
settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []string{runtime.GOOS + "/" + runtime.GOARCH, runtime.GOOS + "/" + secondArch}}}
|
||||||
|
profile, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
|
||||||
|
realGo, err := exec.LookPath("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
script := `#!/bin/sh
|
||||||
|
if [ "$1" != build ]; then exec "$RELEASEKIT_REAL_GO" "$@"; fi
|
||||||
|
output=
|
||||||
|
previous=
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$previous" = -o ]; then output=$arg; fi
|
||||||
|
previous=$arg
|
||||||
|
done
|
||||||
|
"$RELEASEKIT_REAL_GO" "$@" || exit $?
|
||||||
|
if [ -f "$RELEASEKIT_BUILD_COUNT" ]; then
|
||||||
|
rm "$output"
|
||||||
|
mkdir "$output"
|
||||||
|
else
|
||||||
|
: > "$RELEASEKIT_BUILD_COUNT"
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("RELEASEKIT_REAL_GO", realGo)
|
||||||
|
t.Setenv("RELEASEKIT_BUILD_COUNT", filepath.Join(t.TempDir(), "count"))
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(context.Background(), []string{"build", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||||
|
first := fmt.Sprintf("fixture-v1.2.3-%s-%s", runtime.GOOS, runtime.GOARCH)
|
||||||
|
second := fmt.Sprintf("fixture-v1.2.3-%s-%s", runtime.GOOS, secondArch)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
first, second = first+".exe", second+".exe"
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(repository, "dist", first))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(content)
|
||||||
|
if status != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), fmt.Sprintf("built %s %x", first, digest)) || strings.Contains(stderr.String(), "built "+second) || !strings.Contains(stderr.String(), "hash build output") || !strings.Contains(stderr.String(), second) {
|
||||||
|
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(repository, "dist", cli.BuildReceiptName)); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("partial build left receipt: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildCommandLeavesNoReceiptAfterSmokeFailure(t *testing.T) {
|
func TestBuildCommandLeavesNoReceiptAfterSmokeFailure(t *testing.T) {
|
||||||
repository := newCLIBuildRepository(t, `package main
|
repository := newCLIBuildRepository(t, `package main
|
||||||
import (
|
import (
|
||||||
@@ -977,6 +1084,15 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompletedDeliveryCleanupFailureReportsCompletedState(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
result := releasekit.DeliveryResult{State: releasekit.DeliveryComplete, ModulePath: "example.com/app", Tag: "v1.2.3", ReleaseURL: "https://example.invalid/release", Uploaded: []releasekit.DeliveryAsset{{Filename: "app", SHA256: "digest"}}}
|
||||||
|
status := reportDeliveryFailure(&output, result, errors.New("remove temporary directory: failed"))
|
||||||
|
if status != 1 || !strings.Contains(output.String(), "hosted delivery completed; local cleanup failed") || !strings.Contains(output.String(), "uploaded app digest") || strings.Contains(output.String(), "retry") {
|
||||||
|
t.Fatalf("status = %d, output = %q", status, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *testing.T) {
|
func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ type DeliveryRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryResult reports hosted content and the state reached by this
|
// DeliveryResult reports hosted content and the state reached by this
|
||||||
// invocation. A partial result may accompany an error. The asset slices are
|
// invocation. A result may accompany an error, including Complete when hosted
|
||||||
|
// delivery succeeded but local managed-build cleanup failed. The asset slices are
|
||||||
// current observations: an asset appears in at most one slice, and early
|
// current observations: an asset appears in at most one slice, and early
|
||||||
// failures may leave some declared assets unreported.
|
// failures may leave some declared assets unreported.
|
||||||
type DeliveryResult struct {
|
type DeliveryResult struct {
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
package releasekit_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
releasekit "go.maximumdirect.net/releasekit"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDeliverReadsLocalBinaryOnceForVerificationAndOnceForUpload(t *testing.T) {
|
|
||||||
repository, build, expected := newDeliveryBenchmarkFixture(t)
|
|
||||||
watcher, err := syscall.InotifyInit1(syscall.IN_NONBLOCK | syscall.IN_CLOEXEC)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer syscall.Close(watcher)
|
|
||||||
// Close events separate successive opens so inotify cannot coalesce them.
|
|
||||||
if _, err := syscall.InotifyAddWatch(watcher, build.Artifacts[0].Path, syscall.IN_OPEN|syscall.IN_CLOSE_NOWRITE); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
state := &deliveryBenchmarkServer{commit: build.CandidateCommit, assets: expected}
|
|
||||||
state.reset(false)
|
|
||||||
server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
|
|
||||||
state.baseURL = "http://" + server.Listener.Addr().String()
|
|
||||||
server.Start()
|
|
||||||
defer server.Close()
|
|
||||||
result, err := releasekit.Deliver(t.Context(), releasekit.DeliveryRequest{
|
|
||||||
RepositoryDir: repository, Product: "Sample App", Version: build.Version,
|
|
||||||
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &build,
|
|
||||||
})
|
|
||||||
if err != nil || result.State != releasekit.DeliveryComplete || len(result.Uploaded) != len(expected) {
|
|
||||||
t.Fatalf("delivery = %#v, error = %v", result, err)
|
|
||||||
}
|
|
||||||
state.verifyUploads(t)
|
|
||||||
opens := 0
|
|
||||||
var events [256]byte
|
|
||||||
for {
|
|
||||||
n, err := syscall.Read(watcher, events[:])
|
|
||||||
if errors.Is(err, syscall.EAGAIN) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if errors.Is(err, syscall.EINTR) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for offset := 0; offset < n; {
|
|
||||||
mask := binary.NativeEndian.Uint32(events[offset+4:])
|
|
||||||
if mask&syscall.IN_Q_OVERFLOW != 0 {
|
|
||||||
t.Fatal("artifact event queue overflowed")
|
|
||||||
}
|
|
||||||
if mask&syscall.IN_OPEN != 0 {
|
|
||||||
opens++
|
|
||||||
}
|
|
||||||
nameLength := binary.NativeEndian.Uint32(events[offset+12:])
|
|
||||||
offset += syscall.SizeofInotifyEvent + int(nameLength)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if opens != 2 {
|
|
||||||
t.Fatalf("local binary opened %d times; want one verification read and one upload read", opens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -307,6 +307,34 @@ func TestDeliverPrebuiltArtifactsRemainCallerOwned(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeliverRejectsOversizedExistingPrebuiltAsset(t *testing.T) {
|
||||||
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
|
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||||
|
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(prebuilt.Artifacts[0].Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
|
defer server.Close()
|
||||||
|
remote.update(func(data *facadeDeliveryData) {
|
||||||
|
data.pages["v1.2.3"] = true
|
||||||
|
data.assets[prebuilt.Artifacts[0].Filename] = append(content, 'x')
|
||||||
|
})
|
||||||
|
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||||
|
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &prebuilt,
|
||||||
|
})
|
||||||
|
if err == nil || result.State != releasekit.DeliveryConflict || len(result.Conflicting) != 1 || result.Conflicting[0].Filename != prebuilt.Artifacts[0].Filename || remote.snapshot().writes != 0 {
|
||||||
|
t.Fatalf("delivery = %#v, error = %v, writes = %d", result, err, remote.snapshot().writes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) {
|
func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) {
|
||||||
repository := newTaggedDeliveryConsumer(t)
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
@@ -402,6 +430,29 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeliverUsesOriginalNoteWithBlobReplacement(t *testing.T) {
|
||||||
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
|
original := buildGit(t, repository, "rev-parse", "HEAD:docs/releases/v1.2.3.md")
|
||||||
|
replacementPath := filepath.Join(t.TempDir(), "note.md")
|
||||||
|
if err := os.WriteFile(replacementPath, []byte("# Fixture v1.2.3\n\nReplacement note.\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replacement := buildGit(t, repository, "hash-object", "-w", replacementPath)
|
||||||
|
buildGit(t, repository, "replace", original, replacement)
|
||||||
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
|
defer server.Close()
|
||||||
|
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||||
|
Destination: facadeDeliveryDestination(server.URL),
|
||||||
|
})
|
||||||
|
if err != nil || result.State != releasekit.DeliveryComplete {
|
||||||
|
t.Fatalf("delivery = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if got := remote.snapshot().createdBody; got != "# Fixture v1.2.3\n\nDelivery fixture.\n" {
|
||||||
|
t.Fatalf("delivered note = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
|
func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
|
||||||
repository := newTwoModuleDeliveryConsumer(t)
|
repository := newTwoModuleDeliveryConsumer(t)
|
||||||
server, remote := newFacadeDeliveryServer(t, repository)
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
@@ -817,6 +868,7 @@ type facadeDeliveryData struct {
|
|||||||
tag string
|
tag string
|
||||||
version string
|
version string
|
||||||
noteBody string
|
noteBody string
|
||||||
|
createdBody string
|
||||||
pages map[string]bool
|
pages map[string]bool
|
||||||
requests int
|
requests int
|
||||||
writes int
|
writes int
|
||||||
@@ -876,6 +928,7 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
|||||||
if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version {
|
if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version {
|
||||||
data.t.Errorf("release payload = %#v", payload)
|
data.t.Errorf("release payload = %#v", payload)
|
||||||
}
|
}
|
||||||
|
data.createdBody = payload.Body
|
||||||
data.pages[data.tag] = true
|
data.pages[data.tag] = true
|
||||||
state.writeRelease(w)
|
state.writeRelease(w)
|
||||||
case "/api/v1/repos/owner/example/releases/10/assets":
|
case "/api/v1/repos/owner/example/releases/10/assets":
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ and pushes only its exact full tag object. It prints the confirmed semantic
|
|||||||
version, full tag, candidate commit, and tag object
|
version, full tag, candidate commit, and tag object
|
||||||
on success. Validation
|
on success. Validation
|
||||||
progress and failed publication state, including the captured publication
|
progress and failed publication state, including the captured publication
|
||||||
endpoint for recovery, are written to standard error. A signal
|
endpoint for recovery, are written to standard error. HTTP(S) endpoint
|
||||||
|
userinfo is masked in diagnostics. A signal
|
||||||
cancels the active operation; if it arrives during a push, the library's
|
cancels the active operation; if it arrives during a push, the library's
|
||||||
bounded reconciliation determines whether the exact tag was accepted.
|
bounded reconciliation determines whether the exact tag was accepted.
|
||||||
Shared validation failures use the same check and finding presentation as
|
Shared validation failures use the same check and finding presentation as
|
||||||
@@ -208,8 +209,9 @@ first and then run `deliver --assets DIR` with the token available. The ordinary
|
|||||||
`deliver` command does not download application dependencies or change the
|
`deliver` command does not download application dependencies or change the
|
||||||
checkout.
|
checkout.
|
||||||
|
|
||||||
The command returns 0 for complete delivery, 1 for an unavailable preflight or
|
The command returns 0 for complete delivery without a local error, 1 for an
|
||||||
a known delivery failure, conflict, or partial delivery, and 2 for usage,
|
unavailable preflight, a known delivery failure, conflict, partial delivery, or
|
||||||
|
local cleanup failure after hosted completion, and 2 for usage,
|
||||||
profile, forge-selection, local credential-loading, or classified preflight
|
profile, forge-selection, local credential-loading, or classified preflight
|
||||||
configuration/access errors. A remote write whose outcome remains uncertain
|
configuration/access errors. A remote write whose outcome remains uncertain
|
||||||
returns 3, including when cancellation prevents reconciliation. Cancellation
|
returns 3, including when cancellation prevents reconciliation. Cancellation
|
||||||
@@ -242,6 +244,7 @@ the selected Git remote (default `origin`), requires its peeled commit to match
|
|||||||
the required full `--commit` object ID, checks out that commit detached, then
|
the required full `--commit` object ID, checks out that commit detached, then
|
||||||
verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those
|
verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those
|
||||||
checks read-only against the existing checkout and does not fetch or check out.
|
checks read-only against the existing checkout and does not fetch or check out.
|
||||||
|
Preparation diagnostics mask userinfo in HTTP(S) remote URLs.
|
||||||
In either mode, it loads the profile from the verified source. `--expected-module`
|
In either mode, it loads the profile from the verified source. `--expected-module`
|
||||||
optionally checks that the profile selects the module chosen by a launcher;
|
optionally checks that the profile selects the module chosen by a launcher;
|
||||||
module selection itself remains profile-owned. The command compares the selected
|
module selection itself remains profile-owned. The command compares the selected
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ module. The default output directory is `<module>/dist`; a relative `OutputDir`
|
|||||||
is resolved from the module, while an absolute path is used as supplied.
|
is resolved from the module, while an absolute path is used as supplied.
|
||||||
Selected artifact filenames are regenerated. Other files in the output
|
Selected artifact filenames are regenerated. Other files in the output
|
||||||
directory are left in place. A failed later build or native version check
|
directory are left in place. A failed later build or native version check
|
||||||
returns the binaries already generated in `BuildResult.Artifacts`.
|
returns completed, hashed binaries in `BuildResult.Artifacts`. If hashing a
|
||||||
|
newly built file fails, the error identifies its path and earlier completed
|
||||||
|
artifacts remain in the result.
|
||||||
|
|
||||||
Builds use the available compatible Go toolchain selected locally; ReleaseKit
|
Builds use the available compatible Go toolchain selected locally; ReleaseKit
|
||||||
does not download a different toolchain automatically. It disables workspace
|
does not download a different toolchain automatically. It disables workspace
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ an upload was confirmed during this invocation; `Pending` means known missing
|
|||||||
work; `Conflicting` means different hosted content was found; `Uncertain` means
|
work; `Conflicting` means different hosted content was found; `Uncertain` means
|
||||||
the remote state could not be resolved.
|
the remote state could not be resolved.
|
||||||
|
|
||||||
|
An error can accompany `complete` when hosted delivery succeeded but removal
|
||||||
|
of managed build files failed. Address the reported local cleanup failure;
|
||||||
|
the completed hosted delivery does not need to be repeated.
|
||||||
|
|
||||||
Fix local or preflight failures before retrying. For partial delivery, fix the
|
Fix local or preflight failures before retrying. For partial delivery, fix the
|
||||||
reported cause and retry the same version. Resolve an unknown remote outcome
|
reported cause and retry the same version. Resolve an unknown remote outcome
|
||||||
before retrying. Investigate conflicts and use a new version if the source or
|
before retrying. Investigate conflicts and use a new version if the source or
|
||||||
|
|||||||
@@ -114,9 +114,12 @@ candidate and remote guards, creates an annotated tag for the captured commit,
|
|||||||
and pushes only that tag object.
|
and pushes only that tag object.
|
||||||
|
|
||||||
A successful result means Git confirmed the push or an exact authoritative tag
|
A successful result means Git confirmed the push or an exact authoritative tag
|
||||||
query confirmed it after a push error. The local tag is retained after a failed
|
query confirmed it after a push error. A failed inspection after local tag
|
||||||
push. `PublicationState` distinguishes a known failure, conflict, confirmed
|
creation can leave the tag ref even when its object ID is unavailable; when
|
||||||
publication, and unresolved outcome. Resolve an unknown result against the
|
the ID was established it remains in `TagObject`. The local tag is retained
|
||||||
captured endpoint before retrying; see the [maintainer procedure](../release.md)
|
after a failed push. `PublicationState` distinguishes a known failure, conflict,
|
||||||
for the recovery steps. The [CLI contract](../cli.md) documents command output
|
confirmed publication, and unresolved outcome. Resolve an unknown result
|
||||||
and exit statuses.
|
against the captured endpoint before retrying; see the [maintainer procedure](../release.md)
|
||||||
|
for the recovery steps. The result's endpoint is the exact URL and can contain
|
||||||
|
credentials; handle it accordingly. The [CLI contract](../cli.md) documents
|
||||||
|
command output and exit statuses.
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ Unknown-write outcomes are reserved for unresolved writes. Unrelated attachments
|
|||||||
|
|
||||||
Attachment downloads and redirects carry the Gitea authorization token only
|
Attachment downloads and redirects carry the Gitea authorization token only
|
||||||
when their scheme, host, and port match the configured service. Cross-origin
|
when their scheme, host, and port match the configured service. Cross-origin
|
||||||
content is downloaded without that token and verified by its digest.
|
content is downloaded without that token and verified by its digest. Downloads
|
||||||
|
follow at most ten redirects; an additional redirect is unverifiable.
|
||||||
|
Comparison reads no more than the declared local size plus one byte; shorter
|
||||||
|
or longer content conflicts, regardless of the server's reported size.
|
||||||
|
|
||||||
Shared delivery uploads only after inspection finds no conflicts or
|
Shared delivery uploads only after inspection finds no conflicts or
|
||||||
unverifiable assets. It uploads missing binaries in filename order and then the
|
unverifiable assets. It uploads missing binaries in filename order and then the
|
||||||
|
|||||||
@@ -11,18 +11,20 @@ errors.
|
|||||||
## 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
|
||||||
stderr to the optional writer while the child runs, then releases its capture
|
stderr to the optional writer while the child runs, retaining only the last
|
||||||
on success. Failures include the captured diagnostic text and preserve the
|
1,048,576 bytes for failure diagnostics. A truncation notice identifies omitted
|
||||||
underlying process or forwarding error.
|
earlier bytes. Successful calls release their capture; failures preserve the
|
||||||
|
underlying process or forwarding error. Dependency preparation uses the same
|
||||||
|
bounded diagnostic capture.
|
||||||
|
|
||||||
`Tool.Output` returns combined stdout and stderr for commands whose output is
|
`Tool.Output` returns combined stdout and stderr for commands whose output is
|
||||||
needed by the caller, such as compiler identification. After the child stops,
|
needed by the caller, such as compiler identification. After the child stops,
|
||||||
the capture's backing bytes transfer to the caller without a second copy. A
|
the capture's backing bytes transfer to the caller without a second copy. A
|
||||||
failed output command returns both those bytes and an error containing the
|
failed output command returns both those bytes and an error containing the
|
||||||
diagnostic text. The capture remains in memory for the duration of the child;
|
diagnostic text. The capture remains in memory for the duration of the child;
|
||||||
the component benchmark compares one 8 MiB write with the same output in
|
the component benchmark compares full and bounded capture for modest output,
|
||||||
32 KiB writes, forwarding to `io.Discard`. Smaller writes incur buffer-growth
|
one 8 MiB write, and 8 MiB in 32 KiB writes, forwarding to `io.Discard`.
|
||||||
allocations. The [output benchmark](../../internal/gotool/performance_test.go)
|
The [output benchmark](../../internal/gotool/performance_test.go)
|
||||||
measures total allocated bytes per operation, excluding subprocess overhead and
|
measures total allocated bytes per operation, excluding subprocess overhead and
|
||||||
diagnostic strings. These totals count all allocations, including superseded
|
diagnostic strings. These totals count all allocations, including superseded
|
||||||
buffers. The returned slice retains the final buffer holding the complete output.
|
buffers. The returned slice retains the final buffer holding the complete output.
|
||||||
@@ -35,8 +37,8 @@ lifecycle and cleanup work and remain a follow-up option.
|
|||||||
Dependency preparation uses a separate command environment so proxy, private
|
Dependency preparation uses a separate command environment so proxy, private
|
||||||
module, checksum, cache, and other effective settings remain available. Its
|
module, checksum, cache, and other effective settings remain available. Its
|
||||||
`go mod download -modfile=` command overrides an alternate module file selected
|
`go mod download -modfile=` command overrides an alternate module file selected
|
||||||
through `GOFLAGS`, preserving ordinary module discovery. Its completed capture
|
through `GOFLAGS`, preserving ordinary module discovery. Its bounded capture
|
||||||
uses the same ownership transfer when formatting a failure.
|
is formatted into the error on failure.
|
||||||
|
|
||||||
## Configuration discovery
|
## Configuration discovery
|
||||||
|
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ not a separate release matrix:
|
|||||||
| Capability | Implementation owner | Existing evidence |
|
| Capability | Implementation owner | Existing evidence |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` |
|
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` |
|
||||||
| Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestCandidateRequestPreservesMarkdownObjectSelection`, `TestLoadSettingsReadsGoValidation`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` |
|
| Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithGoValidationSettings`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestCandidateRequestPreservesMarkdownObjectSelection`, `TestLoadSettingsReadsGoValidation`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` |
|
||||||
| Committed-file validation: formatting, workspace files, local replacements, and file size | `internal/validation`, `internal/candidate`, `releasekit.Check`, `releasekit.Publish`, CLI profile conversion | `TestCheckGoFormatUsesCapturedRegularGoBlobs`, `TestCheckWorkspaceFilesReportsCommittedBlobNames`, `TestCheckLocalReplacementsParsesCommittedModuleDeclarations`, `TestCheckLocalReplacementsFollowsCommittedModuleSymlinks`, `TestCheckFileSizeUsesStrictLimitsAndDefault`, `TestCheckFileSizeAppliesLiteralPathExclusions`, `TestCheckFileSizeUsesCommittedBlobSizes`, `TestCheckFileSizeUsesOnlyCandidateFilesIndividually` |
|
| Committed-file validation: formatting, workspace files, local replacements, and file size | `internal/validation`, `internal/candidate`, `releasekit.Check`, `releasekit.Publish`, CLI profile conversion | `TestCheckGoFormatUsesCapturedRegularGoBlobs`, `TestCheckWorkspaceFilesReportsCommittedBlobNames`, `TestCheckLocalReplacementsParsesCommittedModuleDeclarations`, `TestCheckLocalReplacementsFollowsCommittedModuleSymlinks`, `TestCheckFileSizeUsesStrictLimitsAndDefault`, `TestCheckFileSizeAppliesLiteralPathExclusions`, `TestCheckFileSizeUsesCommittedBlobSizes`, `TestCheckFileSizeUsesOnlyCandidateFilesIndividually` |
|
||||||
| Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` |
|
| Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownDocumentsParseIdenticalAndDistinctBlobs`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` |
|
||||||
| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestOwnerReusesSettingsUntilInheritedEnvironmentChanges`, `TestOwnerRefreshesWhenPersistentSettingsChange`, `TestCheckSharesGoDiscoveryWithAfterValidateCallback`, `TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree`, `TestRunValidatesOnlyTheSelectedModule`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` |
|
| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestOwnerReusesSettingsUntilInheritedEnvironmentChanges`, `TestOwnerRefreshesWhenPersistentSettingsChange`, `TestCheckSharesGoDiscoveryWithAfterValidateCallback`, `TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree`, `TestRunValidatesOnlyTheSelectedModule`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` |
|
||||||
| Batched local Git blob reads | `internal/git`, `internal/process` | `TestBlobReaderReadsLooseAndPackedObjects`, `TestBlobReaderMissingObjectKeepsBatchSessionUsable`, `TestCandidateObjectReadsStayLocalInPartialClones` |
|
| Batched local Git blob reads | `internal/git`, `internal/process` | `TestBlobReaderReadsLooseAndPackedObjects`, `TestBlobReaderMissingObjectKeepsBatchSessionUsable`, `TestCandidateObjectReadsStayLocalInPartialClones` |
|
||||||
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` |
|
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` |
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ artifact or executing a target binary. Test, vet, and package-compilation
|
|||||||
failures retain their `go-test`, `go-vet`, and `package-compile` identities,
|
failures retain their `go-test`, `go-vet`, and `package-compile` identities,
|
||||||
selected target where applicable, and underlying causes. Its
|
selected target where applicable, and underlying causes. Its
|
||||||
request carries the captured repository directory and commit, output writer,
|
request carries the captured repository directory and commit, output writer,
|
||||||
and internal settings. When selected, the Go-format check loads the candidate
|
and internal settings. The Go commands run before the committed-tree sequence,
|
||||||
tree once, reads regular Go blobs from the captured commit, and compares them
|
which owns its reader's lifetime and close-error attribution. When selected,
|
||||||
with go/format output. It uses the selected inputs, including vendor and explicit
|
the Go-format check loads the candidate tree once, reads regular Go blobs from
|
||||||
shared files, and skips
|
the captured commit, and compares them with go/format output. It uses the
|
||||||
symlink blobs, reports all unformatted paths, and stops with a contextual cause
|
selected inputs, including vendor and explicit shared files, skips symlink
|
||||||
|
blobs, reports all unformatted paths, and stops with a contextual cause
|
||||||
on parse or inspection errors. It runs before product checks and build callbacks.
|
on parse or inspection errors. It runs before product checks and build callbacks.
|
||||||
`Request.ReadCandidateTree` lets selected file checks request candidate
|
`Request.ReadCandidateTree` lets selected file checks request candidate
|
||||||
contents; the Go-only path does not enumerate the tree.
|
contents; the Go-only path does not enumerate the tree.
|
||||||
@@ -109,9 +110,11 @@ observations remain out of scope under either setting. Observations go to the
|
|||||||
provided writer; a nil writer keeps library use quiet. Public selection is
|
provided writer; a nil writer keeps library use quiet. Public selection is
|
||||||
defined in the [configuration reference](../config.md) and GoDoc.
|
defined in the [configuration reference](../config.md) and GoDoc.
|
||||||
|
|
||||||
Committed-tree metadata and blob reads disable lazy fetching and Git transports
|
ReleaseKit-owned Git commands inspect original objects even when replacement
|
||||||
for those child commands. Missing local objects produce inspection errors;
|
refs exist. Committed-tree metadata and blob reads also disable lazy fetching
|
||||||
source preparation retains its separate network-capable operations. See Git's
|
and Git transports for those child commands. Missing local objects produce
|
||||||
|
inspection errors; source preparation retains its separate network-capable
|
||||||
|
operations. See Git's
|
||||||
[lazy-fetch controls](https://git-scm.com/docs/git) for the command mechanism.
|
[lazy-fetch controls](https://git-scm.com/docs/git) for the command mechanism.
|
||||||
Candidate and delivery release-note reads use the same local object boundary.
|
Candidate and delivery release-note reads use the same local object boundary.
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ with `git tag -d vX.Y.Z` before correcting the candidate or retrying. Do not
|
|||||||
move, overwrite, or delete a tag that exists remotely. If a different object
|
move, overwrite, or delete a tag that exists remotely. If a different object
|
||||||
uses the version, choose a new version after resolving the conflict.
|
uses the version, choose a new version after resolving the conflict.
|
||||||
|
|
||||||
|
If local tag inspection fails after creation, the reported ref may remain even
|
||||||
|
when no tag object ID was obtained. Inspect that ref before recovery.
|
||||||
|
|
||||||
If validation or a guard fails before this invocation pushes, correct and
|
If validation or a guard fails before this invocation pushes, correct and
|
||||||
commit the candidate, publish `main`, and run the command again. A retained
|
commit the candidate, publish `main`, and run the command again. A retained
|
||||||
local tag still requires the authoritative-state check above before removal;
|
local tag still requires the authoritative-state check above before removal;
|
||||||
|
|||||||
106
docs/releases/v0.7.0.md
Normal file
106
docs/releases/v0.7.0.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# ReleaseKit v0.7.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ReleaseKit improves source-publication integrity, cancellation, credential-safe
|
||||||
|
diagnostics, partial-result reporting, and temporary-file cleanup. It also bounds
|
||||||
|
retained Go failure diagnostics and remote attachment comparison work while
|
||||||
|
preserving the existing public facades and configuration formats.
|
||||||
|
|
||||||
|
## Publication and Git operations
|
||||||
|
|
||||||
|
- Exact tag pushes disable submodule recursion, correcting inherited
|
||||||
|
`push.recurseSubmodules=only` settings that could report success without
|
||||||
|
publishing the release tag.
|
||||||
|
- ReleaseKit-owned Git inspection and preparation use original objects when
|
||||||
|
replacement refs exist. Committed module identity, release notes, validation
|
||||||
|
inputs, and build source eligibility use that consistent view. Ordinary dirty
|
||||||
|
builds remain supported, with their source eligibility reported separately.
|
||||||
|
- A verified local tag object remains available in `PublicationResult.TagObject`
|
||||||
|
when later inspection fails. Errors identify a possibly retained local ref
|
||||||
|
when its object ID could not be established. Failed verification stops before
|
||||||
|
a push.
|
||||||
|
- HTTP(S) userinfo is masked in owned Git diagnostics and CLI output, including
|
||||||
|
literal apostrophes in credentials and failed CI checkout preparation. Native
|
||||||
|
Git authentication, exact endpoint comparisons, and the raw captured
|
||||||
|
`PublicationResult.Endpoint` value retain their existing behavior.
|
||||||
|
- Child environment overlays preserve the working-directory-derived `PWD` on
|
||||||
|
POSIX systems. Native Git hooks now receive the expected directory environment
|
||||||
|
when ReleaseKit is invoked from another checkout or module subdirectory.
|
||||||
|
|
||||||
|
## Cancellation and diagnostic retention
|
||||||
|
|
||||||
|
- Buffered subprocess output has bounded I/O teardown after cancellation or
|
||||||
|
child exit, including when descendants retain inherited output descriptors.
|
||||||
|
Piped Git-reader cancellation closes owned pipes and joins child cleanup.
|
||||||
|
Cancellation remains recognizable through `errors.Is`; ordinary execution
|
||||||
|
failures preserve their underlying causes.
|
||||||
|
- A queued blob read rechecks cancellation after acquiring the reader mutex,
|
||||||
|
preserving a healthy batch session for later reads.
|
||||||
|
- Status-only Go commands and dependency preparation retain a 1 MiB diagnostic
|
||||||
|
tail. Failed commands identify omitted earlier output. Existing streaming
|
||||||
|
output continues to be forwarded in full, and commands requiring returned
|
||||||
|
output keep their complete capture.
|
||||||
|
|
||||||
|
## Builds and hosted delivery
|
||||||
|
|
||||||
|
- Completed build artifacts are reported only after hashing succeeds. A hashing
|
||||||
|
failure identifies the affected output and preserves earlier completed
|
||||||
|
artifacts and their digests.
|
||||||
|
- Temporary allocation and cleanup failures retain their paths and underlying
|
||||||
|
causes, including candidate builds, native version checks, delivery builds,
|
||||||
|
tag-message files, and build receipts.
|
||||||
|
- A managed-build cleanup failure after confirmed hosted delivery returns an
|
||||||
|
error alongside `DeliveryComplete` and the confirmed asset observations. The
|
||||||
|
CLI reports completed remote work and the local cleanup failure, returning
|
||||||
|
operational status 1.
|
||||||
|
- Gitea attachment downloads follow at most ten redirects, retaining cycle
|
||||||
|
detection and per-origin authorization handling. Limit exhaustion is
|
||||||
|
unverifiable content.
|
||||||
|
- Remote attachment comparison reads at most the locally verified expected size
|
||||||
|
plus one byte. Shorter or longer content conflicts; equal-length content still
|
||||||
|
requires the expected digest. Read, close, and cancellation errors remain
|
||||||
|
inspection failures, with existing attempted-write reconciliation preserved.
|
||||||
|
|
||||||
|
Shared Git observations, validation sequencing, artifact declaration matching,
|
||||||
|
and CLI workflows were consolidated internally. Consumer-facing types continue
|
||||||
|
to belong to the root `releasekit` and runtime `buildinfo` facades.
|
||||||
|
|
||||||
|
## Compatibility and upgrade guidance
|
||||||
|
|
||||||
|
The module identifier remains `go.maximumdirect.net/releasekit`, and the Go
|
||||||
|
consumer minimum remains 1.26.0. Existing v0.6.0 API calls, profile fields, and
|
||||||
|
build receipt formats remain supported. This minor release changes operational
|
||||||
|
behavior through the diagnostic and redirect budgets and cleanup-error reporting
|
||||||
|
described above.
|
||||||
|
|
||||||
|
When adopting v0.7.0:
|
||||||
|
|
||||||
|
1. Update library and CLI dependencies to v0.7.0 using the existing import paths
|
||||||
|
and profiles.
|
||||||
|
2. Handle a delivery error together with `DeliveryResult.State`. For
|
||||||
|
`DeliveryComplete`, address the reported local cleanup path; the confirmed
|
||||||
|
hosted delivery is already complete. Continue to resolve partial, conflicting,
|
||||||
|
and unknown outcomes through their existing recovery guidance.
|
||||||
|
3. Retain candidate validation's streamed `Output` when complete test diagnostics
|
||||||
|
are needed. Status-only failure text now contains the retained tail and an
|
||||||
|
omission notice when applicable.
|
||||||
|
4. When using Git replacement refs, expect release evidence to be evaluated
|
||||||
|
against original committed objects. A build of dirty or replacement-derived
|
||||||
|
working content can succeed while remaining ineligible for binary delivery.
|
||||||
|
5. Inspect the reported local tag ref after a tag-creation or verification
|
||||||
|
failure, including when `TagObject` is empty. Continue to treat the raw
|
||||||
|
`PublicationResult.Endpoint` as sensitive when handling it in application
|
||||||
|
output or logs.
|
||||||
|
|
||||||
|
See the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/publish.md),
|
||||||
|
[build guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/build.md),
|
||||||
|
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/delivery.md),
|
||||||
|
[CLI contract](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/cli.md),
|
||||||
|
and [Gitea integration guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/integrations/gitea.md)
|
||||||
|
for current behavior. Source publication and hosted delivery retain their
|
||||||
|
separate outcomes under the [maintainer procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/oss/attribution.md)
|
||||||
67
docs/roadmap/deferred-resources.md
Normal file
67
docs/roadmap/deferred-resources.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Deferred resource work
|
||||||
|
|
||||||
|
These two proposals remain deferred after the completed audit remediation for
|
||||||
|
v0.7.0. Their assessments are complete; production limits and exclusion indexing
|
||||||
|
await a future decision supported by consumer needs. The assessment evidence
|
||||||
|
was recorded in commit `9dc87ae`.
|
||||||
|
|
||||||
|
## Forge JSON response budgets
|
||||||
|
|
||||||
|
**Status:** Deferred; local sizing assessment complete.
|
||||||
|
|
||||||
|
Forge JSON decoding can consume memory proportional to response size. Release
|
||||||
|
notes and unpaginated attachment lists have no established supported maximum.
|
||||||
|
A future proposal should establish an appropriate response envelope before
|
||||||
|
selecting byte/time budgets or configuration. Any limit must retain context
|
||||||
|
cancellation, useful error classification, and uncertain-write reconciliation:
|
||||||
|
a rejected response does not establish that a create or upload failed remotely.
|
||||||
|
|
||||||
|
The [Gitea fixtures](../../internal/gitea/backend_test.go) construct repository,
|
||||||
|
release, tag, and attachment responses. Representative encoded fixtures measured
|
||||||
|
81 bytes for repository identity, 136 with permissions, 125 for a sparse release,
|
||||||
|
144 for a populated release, 73 for a one-item attachment list, and 62 for an
|
||||||
|
upload response, including the encoder's newline. Four literal release-ID
|
||||||
|
fixtures measured 21, 28, 29, and 29 bytes without a newline. Generated URLs,
|
||||||
|
notes, and lists vary with fixture inputs.
|
||||||
|
|
||||||
|
The checked-in v0.1.0–v0.6.0 release notes measured 1,235–7,899 bytes during the
|
||||||
|
assessment. These observations describe local examples and establish no
|
||||||
|
deployment or protocol maximum. Production response caps remain deferred.
|
||||||
|
|
||||||
|
## File-size exclusion indexing
|
||||||
|
|
||||||
|
**Status:** Deferred; benchmark assessment complete.
|
||||||
|
|
||||||
|
The current [exclusion scan](../../internal/validation/file_size.go) compares
|
||||||
|
each selected file with the normalized exclusions until a match is found.
|
||||||
|
The adoption decision weighs measured speedups against allocation and
|
||||||
|
path-walking costs for consumer workloads. Preserve literal component boundaries,
|
||||||
|
module-relative normalization, shared inputs, and the first oversized-file result.
|
||||||
|
|
||||||
|
The [benchmark-only prototype](../../internal/validation/file_size_benchmark_test.go)
|
||||||
|
compares the scan with a normalized set and ancestor lookups. It covers 1,000
|
||||||
|
and 10,000 paths, 1/10/100/1,000 exclusions, shallow/deep paths, early matches,
|
||||||
|
and nonmatches. Each measured operation includes normalization and one index
|
||||||
|
construction where applicable; fixture strings are prepared outside timing.
|
||||||
|
|
||||||
|
On Linux/amd64, Go 1.26.8, Intel Core i5-7260U, 64 combinations ran with three
|
||||||
|
samples each using:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go test ./internal/validation \
|
||||||
|
-run '^$' -bench '^BenchmarkFileSizeExclusions$' -benchtime=100ms -count=3
|
||||||
|
```
|
||||||
|
|
||||||
|
Representative median times and allocations were:
|
||||||
|
|
||||||
|
| Paths / exclusions / case | Scan | Index |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1,000 / 1 / shallow early match | 24.5 µs, 16 B, 1 allocation | 44.0 µs, 16 B, 1 allocation |
|
||||||
|
| 1,000 / 1 / deep nonmatch | 25.7 µs, 16 B, 1 allocation | 119.0 µs, 16 B, 1 allocation |
|
||||||
|
| 10,000 / 1,000 / shallow early match | 270 µs, 16,384 B, 1 allocation | 564 µs, 70,992 B, 6 allocations |
|
||||||
|
| 10,000 / 1,000 / shallow nonmatch | 227.0 ms, 16,384 B, 1 allocation | 521 µs, 70,992 B, 6 allocations |
|
||||||
|
| 10,000 / 1,000 / deep nonmatch | 233.4 ms, 16,384 B, 1 allocation | 1.36 ms, 70,992 B, 6 allocations |
|
||||||
|
|
||||||
|
The prototype was slower for small lists and early matches, and faster for
|
||||||
|
large nonmatching lists with higher allocation costs. Production matching
|
||||||
|
remains unchanged pending an adoption decision.
|
||||||
@@ -90,3 +90,30 @@ func ExpectedBuild(binaryName, version string, targets []Target) []Declaration {
|
|||||||
}
|
}
|
||||||
return declarations
|
return declarations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateDeclarations matches recorded artifact identities to selected targets.
|
||||||
|
func ValidateDeclarations(binaryName, version string, targets []Target, recorded []Declaration) error {
|
||||||
|
if len(recorded) != len(targets) {
|
||||||
|
return fmt.Errorf("artifact and target counts do not match")
|
||||||
|
}
|
||||||
|
want := make(map[Target]string, len(targets))
|
||||||
|
for _, declaration := range ExpectedBuild(binaryName, version, targets) {
|
||||||
|
target := declaration.Target
|
||||||
|
if !ValidComponent(target.OS) || !ValidComponent(target.Arch) {
|
||||||
|
return fmt.Errorf("invalid target %q/%q", target.OS, target.Arch)
|
||||||
|
}
|
||||||
|
if _, exists := want[target]; exists {
|
||||||
|
return fmt.Errorf("repeats target %q/%q", target.OS, target.Arch)
|
||||||
|
}
|
||||||
|
want[target] = declaration.Filename
|
||||||
|
}
|
||||||
|
seen := make(map[Target]bool, len(recorded))
|
||||||
|
for _, declaration := range recorded {
|
||||||
|
name, ok := want[declaration.Target]
|
||||||
|
if !ok || seen[declaration.Target] || declaration.Filename != name {
|
||||||
|
return fmt.Errorf("artifact %q does not match a selected target and its declared name", declaration.Filename)
|
||||||
|
}
|
||||||
|
seen[declaration.Target] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,3 +56,31 @@ func TestValidComponentPreservesBuildAndReceiptRules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateDeclarationsMatchesTargetsAndNames(t *testing.T) {
|
||||||
|
linux := Target{OS: "linux", Arch: "amd64"}
|
||||||
|
windows := Target{OS: "windows", Arch: "arm64"}
|
||||||
|
first := Declaration{Target: linux, Filename: "app-v1.2.3-linux-amd64"}
|
||||||
|
second := Declaration{Target: windows, Filename: "app-v1.2.3-windows-arm64.exe"}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
targets []Target
|
||||||
|
recorded []Declaration
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"reordered", []Target{linux, windows}, []Declaration{second, first}, true},
|
||||||
|
{"count", []Target{linux, windows}, []Declaration{first}, false},
|
||||||
|
{"duplicate target", []Target{linux, linux}, []Declaration{first, first}, false},
|
||||||
|
{"duplicate artifact", []Target{linux, windows}, []Declaration{first, first}, false},
|
||||||
|
{"incorrect name", []Target{linux}, []Declaration{{Target: linux, Filename: "other"}}, false},
|
||||||
|
{"unknown target", []Target{linux}, []Declaration{second}, false},
|
||||||
|
{"unsafe target", []Target{{OS: "linux/other", Arch: "amd64"}}, []Declaration{{Target: linux, Filename: first.Filename}}, false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
err := ValidateDeclarations("app", "v1.2.3", test.targets, test.recorded)
|
||||||
|
if (err == nil) != test.valid {
|
||||||
|
t.Fatalf("ValidateDeclarations() error = %v, valid = %t", err, test.valid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package build
|
package build
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -9,13 +10,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
"go.maximumdirect.net/releasekit/internal/tempdir"
|
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
@@ -48,27 +50,25 @@ type Result struct {
|
|||||||
SourceVerified bool
|
SourceVerified bool
|
||||||
Settings artifact.Settings
|
Settings artifact.Settings
|
||||||
Version string
|
Version string
|
||||||
MainPackage string
|
|
||||||
BinaryName string
|
|
||||||
OutputDir string
|
OutputDir string
|
||||||
Targets []Target
|
|
||||||
Artifacts []Artifact
|
Artifacts []Artifact
|
||||||
Compiler string
|
Compiler string
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||||
|
return buildResolved(ctx, request, os.RemoveAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildResolved(ctx context.Context, request Request, remove func(string) error) (result Result, resultErr error) {
|
||||||
identity := request.Identity
|
identity := request.Identity
|
||||||
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
|
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
|
||||||
result := Result{
|
result = Result{
|
||||||
RepositoryDir: identity.RepositoryDir,
|
RepositoryDir: identity.RepositoryDir,
|
||||||
ModuleDir: identity.ModuleDir,
|
ModuleDir: identity.ModuleDir,
|
||||||
ModulePath: identity.ModulePath,
|
ModulePath: identity.ModulePath,
|
||||||
Tag: identity.Tag,
|
Tag: identity.Tag,
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
Version: identity.Version,
|
Version: identity.Version,
|
||||||
MainPackage: settings.MainPackage,
|
|
||||||
BinaryName: settings.BinaryName,
|
|
||||||
Targets: settings.Targets,
|
|
||||||
}
|
}
|
||||||
if !versioncontract.ValidStableVersion(identity.Version) {
|
if !versioncontract.ValidStableVersion(identity.Version) {
|
||||||
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version)
|
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version)
|
||||||
@@ -76,14 +76,14 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
|
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
|
||||||
return result, errors.New("build requires a resolved module identity")
|
return result, errors.New("build requires a resolved module identity")
|
||||||
}
|
}
|
||||||
if len(result.Targets) != 0 {
|
if len(settings.Targets) != 0 {
|
||||||
if settings.MainPackage == "" {
|
if settings.MainPackage == "" {
|
||||||
return result, errors.New("build requires a main package when targets are selected")
|
return result, errors.New("build requires a main package when targets are selected")
|
||||||
}
|
}
|
||||||
if !artifact.ValidComponent(settings.BinaryName) {
|
if !artifact.ValidComponent(settings.BinaryName) {
|
||||||
return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName)
|
return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName)
|
||||||
}
|
}
|
||||||
for _, target := range result.Targets {
|
for _, target := range settings.Targets {
|
||||||
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
||||||
return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch)
|
return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch)
|
||||||
}
|
}
|
||||||
@@ -102,17 +102,17 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, fmt.Errorf("resolve module directory: %w", err)
|
return result, fmt.Errorf("resolve module directory: %w", err)
|
||||||
}
|
}
|
||||||
result.ModuleDir = moduleDir
|
result.ModuleDir = moduleDir
|
||||||
commit, err := gitCommit(ctx, repositoryDir)
|
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, fmt.Errorf("read source commit: %w", err)
|
||||||
}
|
}
|
||||||
result.CandidateCommit = commit
|
result.CandidateCommit = commit
|
||||||
cleanBefore, err := cleanSource(ctx, repositoryDir)
|
statusBefore, err := git.WorktreeStatus(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, fmt.Errorf("inspect build source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Targets) == 0 {
|
if len(settings.Targets) == 0 {
|
||||||
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
|
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
goHost := goTool.HostTarget()
|
goHost := goTool.HostTarget()
|
||||||
host := Target{OS: goHost.OS, Arch: goHost.Arch}
|
host := Target{OS: goHost.OS, Arch: goHost.Arch}
|
||||||
|
|
||||||
for index, target := range result.Targets {
|
for index, target := range settings.Targets {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return result, fmt.Errorf("build canceled: %w", err)
|
return result, fmt.Errorf("build canceled: %w", err)
|
||||||
}
|
}
|
||||||
@@ -156,10 +156,10 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
|
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
|
||||||
}
|
}
|
||||||
artifact := Artifact{Path: path, Filename: filename, Target: target}
|
artifact := Artifact{Path: path, Filename: filename, Target: target}
|
||||||
result.Artifacts = append(result.Artifacts, artifact)
|
if err := hashArtifact(&artifact); err != nil {
|
||||||
if err := hashArtifact(&result.Artifacts[len(result.Artifacts)-1]); err != nil {
|
|
||||||
return result, fmt.Errorf("hash build output %q: %w", path, err)
|
return result, fmt.Errorf("hash build output %q: %w", path, err)
|
||||||
}
|
}
|
||||||
|
result.Artifacts = append(result.Artifacts, artifact)
|
||||||
}
|
}
|
||||||
|
|
||||||
var smokePath string
|
var smokePath string
|
||||||
@@ -174,7 +174,11 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("create native smoke directory: %w", err)
|
return result, fmt.Errorf("create native smoke directory: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporaryDir)
|
defer func() {
|
||||||
|
if err := remove(temporaryDir); err != nil {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("remove native smoke directory %q: %w", temporaryDir, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
smokePath = filepath.Join(temporaryDir, settings.BinaryName)
|
smokePath = filepath.Join(temporaryDir, settings.BinaryName)
|
||||||
if host.OS == "windows" {
|
if host.OS == "windows" {
|
||||||
smokePath += ".exe"
|
smokePath += ".exe"
|
||||||
@@ -192,30 +196,18 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.Version); err != nil {
|
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.Version); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
commitAfter, err := gitCommit(ctx, repositoryDir)
|
commitAfter, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, fmt.Errorf("read source commit: %w", err)
|
||||||
}
|
}
|
||||||
cleanAfter, err := cleanSource(ctx, repositoryDir)
|
statusAfter, err := git.WorktreeStatus(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, fmt.Errorf("inspect build source: %w", err)
|
||||||
}
|
}
|
||||||
result.SourceVerified = cleanBefore && cleanAfter && commitAfter == commit
|
result.SourceVerified = statusBefore == "" && statusAfter == "" && commitAfter == commit
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
|
|
||||||
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
|
|
||||||
output, err := command.Output()
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return "", fmt.Errorf("read source commit: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("read source commit: %w", err)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(output)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTarget(ctx context.Context, goTool *gotool.Tool, moduleDir, mainPackage, outputPath, version string, target Target) error {
|
func buildTarget(ctx context.Context, goTool *gotool.Tool, moduleDir, mainPackage, outputPath, version string, target Target) error {
|
||||||
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
||||||
err := goTool.Run(ctx, moduleDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
err := goTool.Run(ctx, moduleDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
||||||
@@ -238,29 +230,14 @@ func hashArtifact(artifact *Artifact) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
|
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
|
||||||
command := exec.CommandContext(ctx, binaryPath, "version")
|
var output bytes.Buffer
|
||||||
output, err := command.CombinedOutput()
|
err := process.Run(ctx, process.Invocation{Args: []string{binaryPath, "version"}, Stdout: &output, Stderr: &output})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(output.String()))
|
||||||
return fmt.Errorf("run native version check: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(string(output)))
|
|
||||||
}
|
}
|
||||||
want := binaryName + " " + version + "\n"
|
want := binaryName + " " + version + "\n"
|
||||||
if string(output) != want {
|
if output.String() != want {
|
||||||
return fmt.Errorf("native version output = %q, want %q", output, want)
|
return fmt.Errorf("native version output = %q, want %q", output.String(), want)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanSource(ctx context.Context, repositoryDir string) (bool, error) {
|
|
||||||
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "status", "--porcelain", "--untracked-files=all")
|
|
||||||
output, err := command.Output()
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return false, fmt.Errorf("inspect build source: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
return false, fmt.Errorf("inspect build source: %w", err)
|
|
||||||
}
|
|
||||||
return len(output) == 0, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package build
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,12 +15,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
||||||
|
return candidateCallback(spec, output, os.RemoveAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateCallback(spec *Spec, output io.Writer, remove func(string) error) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
||||||
if spec == nil || len(spec.Targets) == 0 {
|
if spec == nil || len(spec.Targets) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
selected := *spec
|
selected := *spec
|
||||||
selected.Targets = append([]Target(nil), spec.Targets...)
|
selected.Targets = append([]Target(nil), spec.Targets...)
|
||||||
return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) error {
|
return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) (resultErr error) {
|
||||||
moduleSubdir, err := filepath.Rel(captured.RepositoryDir, captured.ModuleDir)
|
moduleSubdir, err := filepath.Rel(captured.RepositoryDir, captured.ModuleDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve captured module path: %w", err)
|
return fmt.Errorf("resolve captured module path: %w", err)
|
||||||
@@ -39,7 +44,11 @@ func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create temporary candidate build output: %w", err)
|
return fmt.Errorf("create temporary candidate build output: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporaryDir)
|
defer func() {
|
||||||
|
if err := remove(temporaryDir); err != nil {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("remove candidate build directory %q: %w", temporaryDir, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
_, err = BuildResolved(ctx, Request{
|
_, err = BuildResolved(ctx, Request{
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
Spec: selected,
|
Spec: selected,
|
||||||
|
|||||||
124
internal/build/cleanup_test.go
Normal file
124
internal/build/cleanup_test.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cleanupFixture(t *testing.T, output string) releaseidentity.Identity {
|
||||||
|
t.Helper()
|
||||||
|
repo := t.TempDir()
|
||||||
|
files := map[string]string{
|
||||||
|
"go.mod": "module example.com/cleanup\n\ngo 1.25.0\n",
|
||||||
|
"main.go": fmt.Sprintf("package main\nimport \"fmt\"\nfunc main() { fmt.Println(%q) }\n", output),
|
||||||
|
}
|
||||||
|
for name, contents := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, name), []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"init", "-b", "main"}, {"config", "user.name", "Fixture"}, {"config", "user.email", "fixture@example.invalid"}, {"add", "."}, {"commit", "-m", "fixture"}} {
|
||||||
|
command := exec.Command("git", args...)
|
||||||
|
command.Dir = repo
|
||||||
|
if data, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v: %s", args, err, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return releaseidentity.Identity{Selection: releaseidentity.Selection{RepositoryDir: repo, ModuleDir: repo}, ModulePath: "example.com/cleanup", Version: "v1.2.3", Tag: "v1.2.3"}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cancelWriter struct{ cancel context.CancelFunc }
|
||||||
|
|
||||||
|
func (writer cancelWriter) Write(p []byte) (int, error) {
|
||||||
|
if strings.Contains(string(p), "checking ") {
|
||||||
|
writer.cancel()
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeSmokeCleanupPreservesBuildOutcome(t *testing.T) {
|
||||||
|
for _, mode := range []string{"success", "smoke error", "cancellation"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
output := "fixture v1.2.3"
|
||||||
|
if mode == "smoke error" {
|
||||||
|
output = "wrong"
|
||||||
|
}
|
||||||
|
identity := cleanupFixture(t, output)
|
||||||
|
otherArch := "arm64"
|
||||||
|
if runtime.GOARCH == otherArch {
|
||||||
|
otherArch = "amd64"
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
request := Request{Identity: identity, OutputDir: t.TempDir(), Spec: Spec{MainPackage: ".", BinaryName: "fixture", Targets: []Target{{OS: runtime.GOOS, Arch: otherArch}}}}
|
||||||
|
if mode == "cancellation" {
|
||||||
|
request.Output = cancelWriter{cancel}
|
||||||
|
}
|
||||||
|
cleanupErr := errors.New("cleanup failed")
|
||||||
|
var removed string
|
||||||
|
result, err := buildResolved(ctx, request, func(path string) error {
|
||||||
|
removed = path
|
||||||
|
_ = os.RemoveAll(path)
|
||||||
|
return cleanupErr
|
||||||
|
})
|
||||||
|
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) || len(result.Artifacts) != 1 || result.Artifacts[0].SHA256 == "" || result.CandidateCommit == "" {
|
||||||
|
t.Fatalf("build = %#v, cleanup = %q, error = %v", result, removed, err)
|
||||||
|
}
|
||||||
|
if mode == "success" && !result.SourceVerified {
|
||||||
|
t.Fatal("completed build lost verified source")
|
||||||
|
}
|
||||||
|
if mode == "smoke error" && !strings.Contains(err.Error(), "native version output") {
|
||||||
|
t.Fatalf("smoke failure missing: %v", err)
|
||||||
|
}
|
||||||
|
if mode == "cancellation" && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation missing: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateBuildCleanupPreservesOutcome(t *testing.T) {
|
||||||
|
for _, mode := range []string{"success", "build failure", "cancellation"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
identity := cleanupFixture(t, "fixture v1.2.3")
|
||||||
|
cleanupErr := errors.New("cleanup failed")
|
||||||
|
var removed string
|
||||||
|
spec := &Spec{MainPackage: ".", BinaryName: "fixture", Targets: []Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}
|
||||||
|
if mode == "build failure" {
|
||||||
|
spec.MainPackage = "./missing"
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
var output io.Writer
|
||||||
|
if mode == "cancellation" {
|
||||||
|
output = cancelWriter{cancel}
|
||||||
|
}
|
||||||
|
callback := candidateCallback(spec, output, func(path string) error {
|
||||||
|
removed = path
|
||||||
|
_ = os.RemoveAll(path)
|
||||||
|
return cleanupErr
|
||||||
|
})
|
||||||
|
err := callback(ctx, candidate.Candidate{RepositoryDir: identity.RepositoryDir, ModuleDir: identity.ModuleDir, ModulePath: identity.ModulePath, Version: identity.Version, Tag: identity.Tag}, nil)
|
||||||
|
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) {
|
||||||
|
t.Fatalf("candidate cleanup = %q, error = %v", removed, err)
|
||||||
|
}
|
||||||
|
if mode == "build failure" && !strings.Contains(err.Error(), "build candidate") {
|
||||||
|
t.Fatalf("build failure missing: %v", err)
|
||||||
|
}
|
||||||
|
if mode == "cancellation" && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation missing: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,18 +194,18 @@ func inspectLocalState(ctx context.Context, repositoryDir string) (localState, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return localState{}, fmt.Errorf("inspect current branch: %w", err)
|
return localState{}, fmt.Errorf("inspect current branch: %w", err)
|
||||||
}
|
}
|
||||||
commitOutput, err := gitOutput(ctx, repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
|
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return localState{}, fmt.Errorf("capture HEAD commit: %w", err)
|
return localState{}, fmt.Errorf("capture HEAD commit: %w", err)
|
||||||
}
|
}
|
||||||
statusOutput, err := gitOutput(ctx, repositoryDir, "status", "--porcelain=v1", "--untracked-files=all")
|
status, err := git.WorktreeStatus(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return localState{}, fmt.Errorf("inspect worktree and index: %w", err)
|
return localState{}, fmt.Errorf("inspect worktree and index: %w", err)
|
||||||
}
|
}
|
||||||
return localState{
|
return localState{
|
||||||
branch: strings.TrimSpace(string(branchOutput)),
|
branch: strings.TrimSpace(string(branchOutput)),
|
||||||
commit: strings.TrimSpace(string(commitOutput)),
|
commit: commit,
|
||||||
status: strings.TrimSpace(string(statusOutput)),
|
status: status,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +215,7 @@ func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byt
|
|||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: commandArgs,
|
Args: commandArgs,
|
||||||
Dir: repositoryDir,
|
Dir: repositoryDir,
|
||||||
|
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
@@ -233,6 +234,7 @@ func hasLocalTag(ctx context.Context, repositoryDir, tag string) (bool, error) {
|
|||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
|
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
|
||||||
Dir: repositoryDir,
|
Dir: repositoryDir,
|
||||||
|
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -17,29 +17,15 @@ func declaredBuildAssets(result internalbuild.Result) ([]artifact.ExpectedAsset,
|
|||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if len(result.Artifacts) != len(settings.Targets) {
|
declarations := make([]artifact.Declaration, 0, len(result.Artifacts))
|
||||||
return nil, fmt.Errorf("build result must contain one artifact for each selected target")
|
for _, built := range result.Artifacts {
|
||||||
|
declarations = append(declarations, artifact.Declaration{Target: built.Target, Filename: built.Filename})
|
||||||
}
|
}
|
||||||
declarations := artifact.ExpectedBuild(settings.BinaryName, result.Version, settings.Targets)
|
if err := artifact.ValidateDeclarations(settings.BinaryName, result.Version, settings.Targets, declarations); err != nil {
|
||||||
wantByTarget := make(map[artifact.Target]string, len(declarations))
|
return nil, fmt.Errorf("build result: %w", err)
|
||||||
for _, declaration := range declarations {
|
|
||||||
target := declaration.Target
|
|
||||||
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
|
||||||
return nil, fmt.Errorf("build result contains an invalid target")
|
|
||||||
}
|
|
||||||
if _, exists := wantByTarget[target]; exists {
|
|
||||||
return nil, fmt.Errorf("build result repeats target %q", target.OS+"/"+target.Arch)
|
|
||||||
}
|
|
||||||
wantByTarget[target] = declaration.Filename
|
|
||||||
}
|
}
|
||||||
assets := make([]artifact.ExpectedAsset, 0, len(result.Artifacts)+1)
|
assets := make([]artifact.ExpectedAsset, 0, len(result.Artifacts)+1)
|
||||||
seenTargets := make(map[artifact.Target]bool, len(result.Artifacts))
|
|
||||||
for _, builtArtifact := range result.Artifacts {
|
for _, builtArtifact := range result.Artifacts {
|
||||||
wantName, ok := wantByTarget[builtArtifact.Target]
|
|
||||||
if !ok || seenTargets[builtArtifact.Target] || builtArtifact.Filename != wantName {
|
|
||||||
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", builtArtifact.Filename)
|
|
||||||
}
|
|
||||||
seenTargets[builtArtifact.Target] = true
|
|
||||||
if filepath.Base(builtArtifact.Path) != builtArtifact.Filename {
|
if filepath.Base(builtArtifact.Path) != builtArtifact.Filename {
|
||||||
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", builtArtifact.Path, builtArtifact.Filename)
|
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", builtArtifact.Path, builtArtifact.Filename)
|
||||||
}
|
}
|
||||||
|
|||||||
102
internal/delivery/cleanup_test.go
Normal file
102
internal/delivery/cleanup_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
|
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
func deliveryCleanupFixture(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
repo := t.TempDir()
|
||||||
|
files := map[string]string{
|
||||||
|
"go.mod": "module example.com/delivery\n\ngo 1.25.0\n",
|
||||||
|
"main.go": "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"fixture v1.2.3\") }\n",
|
||||||
|
"docs/releases/v1.2.3.md": "# Fixture v1.2.3\n\nNote.\n",
|
||||||
|
}
|
||||||
|
for name, contents := range files {
|
||||||
|
path := filepath.Join(repo, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"init", "-b", "main"}, {"config", "user.name", "Fixture"}, {"config", "user.email", "fixture@example.invalid"}, {"add", "."}, {"commit", "-m", "fixture"}, {"tag", "v1.2.3"}} {
|
||||||
|
command := exec.Command("git", args...)
|
||||||
|
command.Dir = repo
|
||||||
|
if data, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v: %s", args, err, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
command := exec.Command("git", "rev-parse", "HEAD")
|
||||||
|
command.Dir = repo
|
||||||
|
commit, err := command.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return repo, strings.TrimSpace(string(commit))
|
||||||
|
}
|
||||||
|
|
||||||
|
type cancelBuildOutput struct{ cancel context.CancelFunc }
|
||||||
|
|
||||||
|
func (writer cancelBuildOutput) Write(p []byte) (int, error) {
|
||||||
|
if strings.Contains(string(p), "building ") {
|
||||||
|
writer.cancel()
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedDeliveryCleanupPreservesOutcome(t *testing.T) {
|
||||||
|
for _, mode := range []string{"complete", "build failure", "cancellation"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
repo, commit := deliveryCleanupFixture(t)
|
||||||
|
note := "# Fixture v1.2.3\n\nNote.\n"
|
||||||
|
backend := &assetBackend{pageBackend: pageBackend{commit: commit, findResults: []pageFindResult{{release: forge.Release{Tag: "v1.2.3", Body: note, Handle: "release", URL: "https://example.invalid/release"}}}}, lists: []assetListResult{{}}}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
spec := internalbuild.Spec{MainPackage: ".", BinaryName: "fixture", Targets: []artifact.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}
|
||||||
|
if mode == "build failure" {
|
||||||
|
spec.MainPackage = "./missing"
|
||||||
|
}
|
||||||
|
request := Request{RepositoryDir: repo, Product: "Fixture", Version: "v1.2.3", Backend: backend, Build: &spec}
|
||||||
|
if mode == "cancellation" {
|
||||||
|
request.Output = cancelBuildOutput{cancel}
|
||||||
|
}
|
||||||
|
cleanupErr := errors.New("cleanup failed")
|
||||||
|
var removed string
|
||||||
|
result, err := deliver(ctx, request, func(path string) error {
|
||||||
|
removed = path
|
||||||
|
_ = os.RemoveAll(path)
|
||||||
|
return cleanupErr
|
||||||
|
})
|
||||||
|
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) || result.CandidateCommit != commit {
|
||||||
|
t.Fatalf("delivery = %#v, cleanup = %q, error = %v", result, removed, err)
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case "complete":
|
||||||
|
if result.State != Complete || len(result.Uploaded) != 2 || result.ReleaseURL == "" {
|
||||||
|
t.Fatalf("completed delivery lost observations: %#v", result)
|
||||||
|
}
|
||||||
|
case "build failure":
|
||||||
|
if result.State != NotAttempted || !strings.Contains(err.Error(), "build delivery assets") {
|
||||||
|
t.Fatalf("build failure lost: %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
case "cancellation":
|
||||||
|
if result.State != NotAttempted || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation lost: %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,11 @@ type Result struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Deliver(ctx context.Context, request Request) (Result, error) {
|
func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||||
result := Result{State: NotAttempted}
|
return deliver(ctx, request, os.RemoveAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliver(ctx context.Context, request Request, remove func(string) error) (result Result, resultErr error) {
|
||||||
|
result = Result{State: NotAttempted}
|
||||||
if !versioncontract.ValidStableVersion(request.Version) {
|
if !versioncontract.ValidStableVersion(request.Version) {
|
||||||
return result, candidate.ErrInvalidVersion
|
return result, candidate.ErrInvalidVersion
|
||||||
}
|
}
|
||||||
@@ -130,7 +134,11 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
|
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporaryDir)
|
defer func() {
|
||||||
|
if err := remove(temporaryDir); err != nil {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("remove delivery build directory %q: %w", temporaryDir, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
|
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
Spec: *request.Build,
|
Spec: *request.Build,
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ func inspectRemoteAssets(ctx context.Context, backend forge.Backend, releaseHand
|
|||||||
case 0:
|
case 0:
|
||||||
observation.State = AssetMissing
|
observation.State = AssetMissing
|
||||||
case 1:
|
case 1:
|
||||||
digest, err := remoteAssetDigest(ctx, backend, matches[0].Handle)
|
digest, sizeMatches, err := remoteAssetDigest(ctx, backend, matches[0].Handle, item.Size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observation.State = AssetUnverifiable
|
observation.State = AssetUnverifiable
|
||||||
observation.Err = err
|
observation.Err = err
|
||||||
} else if digest == item.SHA256 {
|
} else if sizeMatches && digest == item.SHA256 {
|
||||||
observation.State = AssetMatching
|
observation.State = AssetMatching
|
||||||
} else {
|
} else {
|
||||||
observation.State = AssetConflicting
|
observation.State = AssetConflicting
|
||||||
@@ -99,18 +99,40 @@ func inspectRemoteAssets(ctx context.Context, backend forge.Backend, releaseHand
|
|||||||
return inspection, nil
|
return inspection, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func remoteAssetDigest(ctx context.Context, backend forge.Backend, handle any) (string, error) {
|
func remoteAssetDigest(ctx context.Context, backend forge.Backend, handle any, expectedSize int64) (string, bool, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
stream, err := backend.OpenAsset(ctx, handle)
|
stream, err := backend.OpenAsset(ctx, handle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", false, err
|
||||||
}
|
}
|
||||||
hasher := sha256.New()
|
hasher := sha256.New()
|
||||||
_, readErr := io.Copy(hasher, stream)
|
read, readErr := io.Copy(hasher, io.LimitReader(stream, expectedSize))
|
||||||
closeErr := stream.Close()
|
excess := false
|
||||||
if err := errors.Join(readErr, closeErr); err != nil {
|
if readErr == nil && read == expectedSize {
|
||||||
return "", &forge.Failure{Kind: forge.FailureUnverifiable, Err: err}
|
var probe [1]byte
|
||||||
|
for attempts := 0; ; attempts++ {
|
||||||
|
var n int
|
||||||
|
n, readErr = stream.Read(probe[:])
|
||||||
|
excess = n != 0
|
||||||
|
if readErr != nil || excess {
|
||||||
|
if readErr == io.EOF {
|
||||||
|
readErr = nil
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
break
|
||||||
|
}
|
||||||
|
if attempts == 99 {
|
||||||
|
readErr = io.ErrNoProgress
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeErr := stream.Close()
|
||||||
|
if err := errors.Join(readErr, closeErr, ctx.Err()); err != nil {
|
||||||
|
return "", false, &forge.Failure{Kind: forge.FailureUnverifiable, Err: err}
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil)), read == expectedSize && !excess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHandle any, inspection Inspection) (result UploadResult, err error) {
|
func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHandle any, inspection Inspection) (result UploadResult, err error) {
|
||||||
|
|||||||
@@ -53,6 +53,88 @@ func TestInspectRemoteAssetsComparesContentAndPreservesUnrelatedAssets(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type controlledAssetStream struct {
|
||||||
|
*bytes.Reader
|
||||||
|
readErr error
|
||||||
|
errorAfter int
|
||||||
|
closeErr error
|
||||||
|
cancel context.CancelFunc
|
||||||
|
read int
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stream *controlledAssetStream) Read(p []byte) (int, error) {
|
||||||
|
n, err := stream.Reader.Read(p)
|
||||||
|
stream.read += n
|
||||||
|
if stream.cancel != nil {
|
||||||
|
stream.cancel()
|
||||||
|
stream.cancel = nil
|
||||||
|
}
|
||||||
|
if stream.readErr != nil && n > 0 && stream.read > stream.errorAfter {
|
||||||
|
err, stream.readErr = stream.readErr, nil
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stream *controlledAssetStream) Close() error {
|
||||||
|
stream.closed = true
|
||||||
|
return stream.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlledAssetBackend struct {
|
||||||
|
pageBackend
|
||||||
|
stream *controlledAssetStream
|
||||||
|
}
|
||||||
|
|
||||||
|
func (backend *controlledAssetBackend) ListAssets(context.Context, any) ([]forge.Asset, error) {
|
||||||
|
return []forge.Asset{{Filename: "asset.bin", Size: 999, Handle: "asset"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (backend *controlledAssetBackend) OpenAsset(context.Context, any) (io.ReadCloser, error) {
|
||||||
|
return backend.stream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectRemoteAssetBoundsContentAndPreservesErrors(t *testing.T) {
|
||||||
|
readErr := errors.New("read failed")
|
||||||
|
closeErr := errors.New("close failed")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, expected, actual string
|
||||||
|
readErr, closeErr error
|
||||||
|
cancel bool
|
||||||
|
errorAfter int
|
||||||
|
state AssetState
|
||||||
|
}{
|
||||||
|
{"empty", "", "", nil, nil, false, 0, AssetMatching},
|
||||||
|
{"shorter", "abcd", "abc", nil, nil, false, 0, AssetConflicting},
|
||||||
|
{"matching", "abcd", "abcd", nil, nil, false, 0, AssetMatching},
|
||||||
|
{"different", "abcd", "wxyz", nil, nil, false, 0, AssetConflicting},
|
||||||
|
{"oversized", "abcd", "abcdefghijk", nil, nil, false, 0, AssetConflicting},
|
||||||
|
{"empty oversized", "", "abc", nil, nil, false, 0, AssetConflicting},
|
||||||
|
{"data and error", "abcd", "abcd", readErr, nil, false, 0, AssetUnverifiable},
|
||||||
|
{"probe data and error", "abcd", "abcde", readErr, nil, false, 4, AssetUnverifiable},
|
||||||
|
{"close failure", "abcd", "abcdefgh", nil, closeErr, false, 0, AssetUnverifiable},
|
||||||
|
{"cancellation", "abcd", "abcd", nil, nil, true, 0, AssetUnverifiable},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
stream := &controlledAssetStream{Reader: bytes.NewReader([]byte(test.actual)), readErr: test.readErr, errorAfter: test.errorAfter, closeErr: test.closeErr}
|
||||||
|
if test.cancel {
|
||||||
|
stream.cancel = cancel
|
||||||
|
}
|
||||||
|
backend := &controlledAssetBackend{stream: stream}
|
||||||
|
inspection, err := inspectRemoteAssets(ctx, backend, "release", []artifact.ExpectedAsset{expectedAsset("asset.bin", []byte(test.expected))})
|
||||||
|
if err != nil || len(inspection.Assets) != 1 || inspection.Assets[0].State != test.state || !stream.closed || stream.read > len(test.expected)+1 {
|
||||||
|
t.Fatalf("inspection = %#v, error = %v, bytes = %d, closed = %t", inspection, err, stream.read, stream.closed)
|
||||||
|
}
|
||||||
|
cause := inspection.Assets[0].Err
|
||||||
|
if test.readErr != nil && !errors.Is(cause, test.readErr) || test.closeErr != nil && !errors.Is(cause, test.closeErr) || test.cancel && !errors.Is(cause, context.Canceled) {
|
||||||
|
t.Fatalf("inspection lost cause: %v", cause)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInspectRemoteAssetsListingFailureDoesNotBecomeUnknownWrite(t *testing.T) {
|
func TestInspectRemoteAssetsListingFailureDoesNotBecomeUnknownWrite(t *testing.T) {
|
||||||
assets := []artifact.ExpectedAsset{expectedAsset("a.bin", []byte("a")), expectedAsset("b.bin", []byte("b"))}
|
assets := []artifact.ExpectedAsset{expectedAsset("a.bin", []byte("a")), expectedAsset("b.bin", []byte("b"))}
|
||||||
listErr := &forge.Failure{Kind: forge.FailureTransport, Err: errors.New("service unavailable")}
|
listErr := &forge.Failure{Kind: forge.FailureTransport, Err: errors.New("service unavailable")}
|
||||||
@@ -230,6 +312,11 @@ func TestUploadMissingAssetsReconcilesOnceAndKeepsLaterAssetsPending(t *testing.
|
|||||||
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("x")},
|
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("x")},
|
||||||
wantState: Conflict, wantCategory: "conflicting",
|
wantState: Conflict, wantCategory: "conflicting",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "oversized bytes conflict", disposition: forge.WriteUncertain,
|
||||||
|
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("oversized")},
|
||||||
|
wantState: Conflict, wantCategory: "conflicting",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "missing after conflict remains conflict", disposition: forge.WriteConflict,
|
name: "missing after conflict remains conflict", disposition: forge.WriteConflict,
|
||||||
wantState: Conflict, wantCategory: "conflicting",
|
wantState: Conflict, wantCategory: "conflicting",
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ func (reader *BlobReader) Read(ctx context.Context, objectID string) ([]byte, er
|
|||||||
|
|
||||||
reader.mu.Lock()
|
reader.mu.Lock()
|
||||||
defer reader.mu.Unlock()
|
defer reader.mu.Unlock()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
|
||||||
|
}
|
||||||
if reader.terminal != nil {
|
if reader.terminal != nil {
|
||||||
return nil, reader.terminal
|
return nil, reader.terminal
|
||||||
}
|
}
|
||||||
@@ -130,6 +133,7 @@ func (reader *BlobReader) startLocked() error {
|
|||||||
Env: map[string]string{
|
Env: map[string]string{
|
||||||
"GIT_NO_LAZY_FETCH": "1",
|
"GIT_NO_LAZY_FETCH": "1",
|
||||||
"GIT_ALLOW_PROTOCOL": "",
|
"GIT_ALLOW_PROTOCOL": "",
|
||||||
|
"GIT_NO_REPLACE_OBJECTS": "1",
|
||||||
},
|
},
|
||||||
Stderr: &reader.stderr,
|
Stderr: &reader.stderr,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -156,6 +157,46 @@ exec sleep 60`)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBlobReaderCanceledQueuedReadKeepsSessionUsable(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
object := gitText(t, fixture.work, "rev-parse", "HEAD:source.txt")
|
||||||
|
reader := NewBlobReader(t.Context(), fixture.work)
|
||||||
|
defer reader.Close()
|
||||||
|
if body, err := reader.Read(t.Context(), object); err != nil || string(body) != "candidate" {
|
||||||
|
t.Fatalf("first read = %q, %v", body, err)
|
||||||
|
}
|
||||||
|
reader.mu.Lock()
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
observed := make(chan struct{})
|
||||||
|
queued := &observedContext{Context: ctx, observed: observed}
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := reader.Read(queued, object)
|
||||||
|
result <- err
|
||||||
|
}()
|
||||||
|
<-observed
|
||||||
|
cancel()
|
||||||
|
reader.mu.Unlock()
|
||||||
|
if err := <-result; !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("queued read error = %v", err)
|
||||||
|
}
|
||||||
|
if body, err := reader.Read(t.Context(), object); err != nil || string(body) != "candidate" {
|
||||||
|
t.Fatalf("later read = %q, %v", body, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type observedContext struct {
|
||||||
|
context.Context
|
||||||
|
once sync.Once
|
||||||
|
observed chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *observedContext) Err() error {
|
||||||
|
err := ctx.Context.Err()
|
||||||
|
ctx.once.Do(func() { close(ctx.observed) })
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) {
|
func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) {
|
||||||
installFakeGit(t, `printf 'fixture Git failure\n' >&2
|
installFakeGit(t, `printf 'fixture Git failure\n' >&2
|
||||||
exit 23`)
|
exit 23`)
|
||||||
|
|||||||
40
internal/git/display.go
Normal file
40
internal/git/display.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Match userinfo through @ before treating quotes as diagnostic delimiters.
|
||||||
|
var httpURLSpan = regexp.MustCompile(`(?i)https?://(?:[^\s/?#<>]*@)?[^\s"'<>]*`)
|
||||||
|
|
||||||
|
// DisplayURL hides HTTP userinfo without changing the endpoint used by Git.
|
||||||
|
func DisplayURL(raw string) string {
|
||||||
|
lower := strings.ToLower(raw)
|
||||||
|
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
authority := raw[strings.Index(raw, "://")+3:]
|
||||||
|
if end := strings.IndexAny(authority, "/?#"); end >= 0 {
|
||||||
|
authority = authority[:end]
|
||||||
|
}
|
||||||
|
if !strings.Contains(authority, "@") {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || parsed.User == nil || parsed.Host == "" {
|
||||||
|
return "[redacted URL]"
|
||||||
|
}
|
||||||
|
parsed.User = url.User("redacted")
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayDiagnostic(message string) string {
|
||||||
|
return httpURLSpan.ReplaceAllStringFunc(message, DisplayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
type displayError struct{ cause error }
|
||||||
|
|
||||||
|
func (err displayError) Error() string { return displayDiagnostic(err.cause.Error()) }
|
||||||
|
func (err displayError) Unwrap() error { return err.cause }
|
||||||
103
internal/git/display_test.go
Normal file
103
internal/git/display_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisplayURL(t *testing.T) {
|
||||||
|
for _, test := range []struct{ input, want string }{
|
||||||
|
{"https://user:password@example.test/repo", "https://redacted@example.test/repo"},
|
||||||
|
{"https://token@example.test/repo", "https://redacted@example.test/repo"},
|
||||||
|
{"https://user:secret'part@example.test/repo", "https://redacted@example.test/repo"},
|
||||||
|
{"https://user%40example:secret%2Fvalue@example.test/repo", "https://redacted@example.test/repo"},
|
||||||
|
{"http://broken%zz:secret@example.test/repo", "[redacted URL]"},
|
||||||
|
{"https://example.test/repo", "https://example.test/repo"},
|
||||||
|
{"git@example.test:owner/repo", "git@example.test:owner/repo"},
|
||||||
|
} {
|
||||||
|
if got := DisplayURL(test.input); got != test.want {
|
||||||
|
t.Errorf("DisplayURL(%q) = %q, want %q", test.input, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayDiagnosticRedactsUserinfoBeforeQuotedContext(t *testing.T) {
|
||||||
|
for _, test := range []struct{ input, want string }{
|
||||||
|
{"fatal: 'https://user:secret'part@example.test/repo': denied", "fatal: 'https://redacted@example.test/repo': denied"},
|
||||||
|
{"failed at https://token'part@example.test", "failed at https://redacted@example.test"},
|
||||||
|
{"fatal: 'https://user:secret%27part@example.test/repo': denied", "fatal: 'https://redacted@example.test/repo': denied"},
|
||||||
|
{"fatal: 'https://broken%zz:secret'part@example.test/repo': denied", "fatal: '[redacted URL]': denied"},
|
||||||
|
{"fatal: 'https://token'part@': denied", "fatal: '[redacted URL]': denied"},
|
||||||
|
{"see 'https://example.test/path' and git@example.test:repo", "see 'https://example.test/path' and git@example.test:repo"},
|
||||||
|
} {
|
||||||
|
if got := displayDiagnostic(test.input); got != test.want {
|
||||||
|
t.Errorf("displayDiagnostic(%q) = %q, want %q", test.input, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRedactsCredentialsInNativeGitErrors(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
for _, remote := range []string{"https://user:secret'part@example.test/repo", "https://user:secret%27part@example.test/repo"} {
|
||||||
|
_, err := Resolve(t.Context(), fixture.work, remote)
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "user:") || !strings.Contains(err.Error(), "example.test/repo") {
|
||||||
|
t.Fatalf("unsafe or incomplete native Git diagnostic: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitFailuresRedactCredentialsAndPreserveCause(t *testing.T) {
|
||||||
|
installFakeGit(t, `case "$*" in
|
||||||
|
*check-ref-format*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' "fatal: unable to access 'https://user:secret@example.test/repo': https://token@example.test/path" >&2
|
||||||
|
exit 23`)
|
||||||
|
endpoint := Endpoint{PushURL: "https://user:secret@example.test/repo", ScopedName: "fixture"}
|
||||||
|
for _, operation := range []struct {
|
||||||
|
name string
|
||||||
|
run func() error
|
||||||
|
}{
|
||||||
|
{"query", func() error { _, err := endpoint.Query(context.Background(), t.TempDir(), "v1.2.3"); return err }},
|
||||||
|
{"push", func() error {
|
||||||
|
return endpoint.PushTag(context.Background(), t.TempDir(), "v1.2.3", strings.Repeat("a", 40))
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(operation.name, func(t *testing.T) {
|
||||||
|
err := operation.run()
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || exit.ExitCode() != 23 {
|
||||||
|
t.Fatalf("error = %v, want Git exit 23", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "token") || strings.Contains(err.Error(), "user") || !strings.Contains(err.Error(), "example.test/repo") {
|
||||||
|
t.Fatalf("unsafe or unhelpful diagnostic: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayErrorPreservesWrappedCause(t *testing.T) {
|
||||||
|
for _, userinfo := range []string{"token", "token'part", "token%27part"} {
|
||||||
|
cause := errors.New("failed at https://" + userinfo + "@example.test/path")
|
||||||
|
err := displayError{cause: cause}
|
||||||
|
if !errors.Is(err, cause) || strings.Contains(err.Error(), "token") {
|
||||||
|
t.Fatalf("sanitized error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndpointChangeRedactsNewDestination(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
endpoint, err := Resolve(t.Context(), fixture.work, "origin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gitRun(t, fixture.work, "remote", "set-url", "--push", "origin", "https://new-token@example.test/repo")
|
||||||
|
err = endpoint.Recheck(t.Context(), fixture.work)
|
||||||
|
if !errors.Is(err, ErrEndpointChanged) || strings.Contains(err.Error(), "new-token") || !strings.Contains(err.Error(), "example.test/repo") {
|
||||||
|
t.Fatalf("endpoint change error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expe
|
|||||||
}
|
}
|
||||||
ref := "refs/tags/" + tag
|
ref := "refs/tags/" + tag
|
||||||
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
|
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
|
||||||
return fmt.Errorf("fetch tag %s from remote %s: %w", tag, remote, err)
|
return fmt.Errorf("fetch tag %s from remote %s: %w", tag, DisplayURL(remote), err)
|
||||||
}
|
}
|
||||||
source, err := InspectTaggedSource(ctx, repositoryDir, tag)
|
source, err := InspectTaggedSource(ctx, repositoryDir, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -60,14 +60,14 @@ type Snapshot struct {
|
|||||||
// command-scoped remote whose read and push URLs resolve to that same endpoint.
|
// command-scoped remote whose read and push URLs resolve to that same endpoint.
|
||||||
func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, error) {
|
func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, error) {
|
||||||
if strings.TrimSpace(remoteName) == "" || strings.HasPrefix(remoteName, "-") {
|
if strings.TrimSpace(remoteName) == "" || strings.HasPrefix(remoteName, "-") {
|
||||||
return Endpoint{}, fmt.Errorf("%w: %q", ErrInvalidRemote, remoteName)
|
return Endpoint{}, fmt.Errorf("%w: %q", ErrInvalidRemote, DisplayURL(remoteName))
|
||||||
}
|
}
|
||||||
urls, err := pushURLs(ctx, repositoryDir, remoteName)
|
urls, err := pushURLs(ctx, repositoryDir, remoteName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Endpoint{}, err
|
return Endpoint{}, err
|
||||||
}
|
}
|
||||||
if len(urls) != 1 {
|
if len(urls) != 1 {
|
||||||
return Endpoint{}, fmt.Errorf("%w: %s resolves to %d distinct destinations", ErrMultiplePushURLs, remoteName, len(urls))
|
return Endpoint{}, fmt.Errorf("%w: %s resolves to %d distinct destinations", ErrMultiplePushURLs, DisplayURL(remoteName), len(urls))
|
||||||
}
|
}
|
||||||
scopedName, err := newScopedName()
|
scopedName, err := newScopedName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,10 +89,10 @@ func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, e
|
|||||||
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
|
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
|
||||||
urls, err := pushURLs(ctx, repositoryDir, endpoint.RemoteName)
|
urls, err := pushURLs(ctx, repositoryDir, endpoint.RemoteName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("re-resolve remote %q: %w", endpoint.RemoteName, err)
|
return fmt.Errorf("re-resolve remote %q: %w", DisplayURL(endpoint.RemoteName), err)
|
||||||
}
|
}
|
||||||
if len(urls) != 1 || urls[0] != endpoint.PushURL {
|
if len(urls) != 1 || urls[0] != endpoint.PushURL {
|
||||||
return fmt.Errorf("%w: remote %q now resolves to %q", ErrEndpointChanged, endpoint.RemoteName, strings.Join(urls, ", "))
|
return fmt.Errorf("%w: remote %q now resolves to %q", ErrEndpointChanged, DisplayURL(endpoint.RemoteName), displayDiagnostic(strings.Join(urls, ", ")))
|
||||||
}
|
}
|
||||||
return endpoint.verifyScopedURLs(ctx, repositoryDir)
|
return endpoint.verifyScopedURLs(ctx, repositoryDir)
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
|
|||||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
|
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
snapshot := Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}
|
snapshot := Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}
|
||||||
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err)
|
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, DisplayURL(endpoint.PushURL), err)
|
||||||
}
|
}
|
||||||
main := RemoteRef{State: RefAbsent}
|
main := RemoteRef{State: RefAbsent}
|
||||||
remoteTag := RemoteRef{State: RefAbsent}
|
remoteTag := RemoteRef{State: RefAbsent}
|
||||||
@@ -119,7 +119,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
|
|||||||
}
|
}
|
||||||
object, name, ok := strings.Cut(line, "\t")
|
object, name, ok := strings.Cut(line, "\t")
|
||||||
if !ok || !validObjectID(object) {
|
if !ok || !validObjectID(object) {
|
||||||
return Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
|
return Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, displayDiagnostic(line))
|
||||||
}
|
}
|
||||||
switch name {
|
switch name {
|
||||||
case mainRef:
|
case mainRef:
|
||||||
@@ -165,7 +165,7 @@ func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir str
|
|||||||
}
|
}
|
||||||
resolved := outputLines(readURL)
|
resolved := outputLines(readURL)
|
||||||
if len(resolved) != 1 || resolved[0] != endpoint.PushURL {
|
if len(resolved) != 1 || resolved[0] != endpoint.PushURL {
|
||||||
return fmt.Errorf("%w: scoped URL resolves to %q, expected %q", ErrEndpointChanged, resolved, endpoint.PushURL)
|
return fmt.Errorf("%w: scoped URL resolves to %q, expected %q", ErrEndpointChanged, displayDiagnostic(strings.Join(resolved, ", ")), DisplayURL(endpoint.PushURL))
|
||||||
}
|
}
|
||||||
// The scoped remote sets its push URL explicitly to the same captured value.
|
// The scoped remote sets its push URL explicitly to the same captured value.
|
||||||
// Git applies the same insteadOf rules to both explicit URLs; pushInsteadOf
|
// Git applies the same insteadOf rules to both explicit URLs; pushInsteadOf
|
||||||
@@ -197,7 +197,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
|
|||||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", remoteName, key, err)
|
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", DisplayURL(remoteName), key, err)
|
||||||
}
|
}
|
||||||
for _, value := range outputLines(output) {
|
for _, value := range outputLines(output) {
|
||||||
config = append(config, "remote."+scopedName+"."+key+"="+value)
|
config = append(config, "remote."+scopedName+"."+key+"="+value)
|
||||||
@@ -209,7 +209,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
|
|||||||
func pushURLs(ctx context.Context, repositoryDir, remoteName string) ([]string, error) {
|
func pushURLs(ctx context.Context, repositoryDir, remoteName string) ([]string, error) {
|
||||||
output, err := run(ctx, repositoryDir, nil, "remote", "get-url", "--push", "--all", remoteName)
|
output, err := run(ctx, repositoryDir, nil, "remote", "get-url", "--push", "--all", remoteName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("resolve push URL for remote %q: %w", remoteName, err)
|
return nil, fmt.Errorf("resolve push URL for remote %q: %w", DisplayURL(remoteName), err)
|
||||||
}
|
}
|
||||||
values := outputLines(output)
|
values := outputLines(output)
|
||||||
unique := make([]string, 0, len(values))
|
unique := make([]string, 0, len(values))
|
||||||
@@ -234,13 +234,15 @@ func run(ctx context.Context, dir string, config []string, args ...string) (stri
|
|||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: append([]string{"git"}, commandArgs...),
|
Args: append([]string{"git"}, commandArgs...),
|
||||||
Dir: dir,
|
Dir: dir,
|
||||||
|
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = displayError{cause: err}
|
||||||
message := strings.TrimSpace(stderr.String())
|
message := strings.TrimSpace(stderr.String())
|
||||||
if message != "" {
|
if message != "" {
|
||||||
return stdout.String(), fmt.Errorf("%w: %s", err, message)
|
return stdout.String(), fmt.Errorf("%w: %s", err, displayDiagnostic(message))
|
||||||
}
|
}
|
||||||
return stdout.String(), err
|
return stdout.String(), err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool
|
|||||||
var stdout, stderr strings.Builder
|
var stdout, stderr strings.Builder
|
||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: []string{"git", "rev-parse", "--show-toplevel"}, Dir: directory,
|
Args: []string{"git", "rev-parse", "--show-toplevel"}, Dir: directory,
|
||||||
Env: map[string]string{"LC_ALL": "C"}, Stdout: &stdout, Stderr: &stderr,
|
Env: map[string]string{"LC_ALL": "C", "GIT_NO_REPLACE_OBJECTS": "1"}, Stdout: &stdout, Stderr: &stderr,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
root, err := normalizeRepositoryRoot(stdout.String())
|
root, err := normalizeRepositoryRoot(stdout.String())
|
||||||
@@ -199,6 +199,15 @@ func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
|
|||||||
return head, nil
|
return head, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorktreeStatus observes repository-wide worktree and index changes.
|
||||||
|
func WorktreeStatus(ctx context.Context, repositoryDir string) (string, error) {
|
||||||
|
status, err := run(ctx, repositoryDir, nil, "status", "--porcelain=v1", "--untracked-files=all")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(status), nil
|
||||||
|
}
|
||||||
|
|
||||||
// InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
|
// InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
|
||||||
// Both lightweight historical tags and annotated tags are supported.
|
// Both lightweight historical tags and annotated tags are supported.
|
||||||
func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {
|
func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {
|
||||||
@@ -206,15 +215,12 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (Tagged
|
|||||||
return TaggedSource{}, err
|
return TaggedSource{}, err
|
||||||
}
|
}
|
||||||
var source TaggedSource
|
var source TaggedSource
|
||||||
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
|
head, err := CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TaggedSource{}, fmt.Errorf("read checkout HEAD: %w", err)
|
return TaggedSource{}, err
|
||||||
}
|
}
|
||||||
source.Head = strings.TrimSpace(head)
|
source.Head = head
|
||||||
if !validObjectID(source.Head) {
|
status, err := WorktreeStatus(ctx, repositoryDir)
|
||||||
return TaggedSource{}, fmt.Errorf("Git returned an invalid checkout commit %q", source.Head)
|
|
||||||
}
|
|
||||||
status, err := run(ctx, repositoryDir, nil, "status", "--porcelain", "--untracked-files=all")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
|
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
|
||||||
}
|
}
|
||||||
@@ -411,7 +417,7 @@ func readLocalObjects(ctx context.Context, repositoryDir string, input io.Reader
|
|||||||
Args: append([]string{"git"}, args...),
|
Args: append([]string{"git"}, args...),
|
||||||
Dir: repositoryDir,
|
Dir: repositoryDir,
|
||||||
// Disable transports as well for Git versions predating lazy-fetch control.
|
// Disable transports as well for Git versions predating lazy-fetch control.
|
||||||
Env: map[string]string{"GIT_NO_LAZY_FETCH": "1", "GIT_ALLOW_PROTOCOL": ""},
|
Env: map[string]string{"GIT_NO_LAZY_FETCH": "1", "GIT_ALLOW_PROTOCOL": "", "GIT_NO_REPLACE_OBJECTS": "1"},
|
||||||
Stdin: input,
|
Stdin: input,
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := InspectLocalTag(context.Background(), fixture.work, tag, object); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
|
source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -54,6 +51,31 @@ func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateAnnotatedTagReportsTemporaryCleanupFailure(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
cleanupErr := errors.New("cleanup failed")
|
||||||
|
var removed string
|
||||||
|
remove := func(path string) error {
|
||||||
|
removed = path
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cleanupErr
|
||||||
|
}
|
||||||
|
object, err := createAnnotatedTag(t.Context(), fixture.work, "v1.2.3", fixture.commit, []byte("note\n"), remove)
|
||||||
|
if !errors.Is(err, cleanupErr) || object == "" || removed == "" || !strings.Contains(err.Error(), removed) {
|
||||||
|
t.Fatalf("tag object = %q, cleanup path = %q, error = %v", object, removed, err)
|
||||||
|
}
|
||||||
|
if got := gitText(t, fixture.work, "rev-parse", "refs/tags/v1.2.3"); got != object {
|
||||||
|
t.Fatalf("local tag = %q, want %q", got, object)
|
||||||
|
}
|
||||||
|
_, err = createAnnotatedTag(t.Context(), fixture.work, "v1.2.3", fixture.commit, []byte("note\n"), remove)
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || !errors.Is(err, cleanupErr) {
|
||||||
|
t.Fatalf("tag creation and cleanup error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTagReferenceValidationUsesGitRules(t *testing.T) {
|
func TestTagReferenceValidationUsesGitRules(t *testing.T) {
|
||||||
fixture := newRemoteFixture(t)
|
fixture := newRemoteFixture(t)
|
||||||
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} {
|
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} {
|
||||||
@@ -142,3 +164,52 @@ func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T)
|
|||||||
t.Fatalf("dirty status = %q", source.Status)
|
t.Fatalf("dirty status = %q", source.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCommittedReadsIgnoreReplacementObjects(t *testing.T) {
|
||||||
|
for _, kind := range []string{"commit", "tree", "blob"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
original := map[string]string{
|
||||||
|
"commit": fixture.commit,
|
||||||
|
"tree": gitText(t, fixture.work, "rev-parse", "HEAD^{tree}"),
|
||||||
|
"blob": gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"),
|
||||||
|
}
|
||||||
|
writeRepoFile(t, fixture.work, "source.txt", "replacement")
|
||||||
|
gitRun(t, fixture.work, "add", "source.txt")
|
||||||
|
gitRun(t, fixture.work, "commit", "-m", "Replacement")
|
||||||
|
replacement := map[string]string{
|
||||||
|
"commit": gitText(t, fixture.work, "rev-parse", "HEAD"),
|
||||||
|
"tree": gitText(t, fixture.work, "rev-parse", "HEAD^{tree}"),
|
||||||
|
"blob": gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"),
|
||||||
|
}
|
||||||
|
gitRun(t, fixture.work, "reset", "--hard", fixture.commit)
|
||||||
|
gitRun(t, fixture.work, "replace", original[kind], replacement[kind])
|
||||||
|
|
||||||
|
content, err := ReadCommitFile(t.Context(), fixture.work, fixture.commit, "source.txt")
|
||||||
|
if err != nil || string(content) != "candidate" {
|
||||||
|
t.Fatalf("committed content = %q, error = %v", content, err)
|
||||||
|
}
|
||||||
|
entries, err := ReadTree(t.Context(), fixture.work, fixture.commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var object string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Path == "source.txt" {
|
||||||
|
object = entry.ObjectID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if object != original["blob"] {
|
||||||
|
t.Fatalf("tree blob = %q, want %q", object, original["blob"])
|
||||||
|
}
|
||||||
|
reader := NewBlobReader(t.Context(), fixture.work)
|
||||||
|
content, err = reader.Read(t.Context(), object)
|
||||||
|
if err != nil || string(content) != "candidate" {
|
||||||
|
t.Fatalf("batch content = %q, error = %v", content, err)
|
||||||
|
}
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ package git
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
|
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
|
||||||
// on the captured commit and returns the tag object's ID.
|
// on the captured commit. Once its type and ID are established, the ID is
|
||||||
|
// returned even if a later verification fails.
|
||||||
func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
|
func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
|
||||||
|
return createAnnotatedTag(ctx, repositoryDir, tag, commit, note, os.Remove)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte, remove func(string) error) (objectID string, resultErr error) {
|
||||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -22,7 +28,11 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string,
|
|||||||
return "", fmt.Errorf("create temporary release note: %w", err)
|
return "", fmt.Errorf("create temporary release note: %w", err)
|
||||||
}
|
}
|
||||||
messagePath := message.Name()
|
messagePath := message.Name()
|
||||||
defer os.Remove(messagePath)
|
defer func() {
|
||||||
|
if err := remove(messagePath); err != nil {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary release note %q: %w", messagePath, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
if _, err := message.Write(note); err != nil {
|
if _, err := message.Write(note); err != nil {
|
||||||
_ = message.Close()
|
_ = message.Close()
|
||||||
return "", fmt.Errorf("write temporary release note: %w", err)
|
return "", fmt.Errorf("write temporary release note: %w", err)
|
||||||
@@ -35,66 +45,47 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
ref := "refs/tags/" + tag
|
ref := "refs/tags/" + tag
|
||||||
|
object, err := inspectCreatedTag(ctx, repositoryDir, ref, commit, note)
|
||||||
|
if err != nil {
|
||||||
|
return object, fmt.Errorf("verify created tag %s; local ref may remain: %w", ref, err)
|
||||||
|
}
|
||||||
|
return object, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectCreatedTag(ctx context.Context, repositoryDir, ref, commit string, note []byte) (string, error) {
|
||||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("inspect created tag %s: %w", tag, err)
|
return "", fmt.Errorf("inspect created tag %s: %w", ref, err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(tagType) != "tag" {
|
if strings.TrimSpace(tagType) != "tag" {
|
||||||
return "", fmt.Errorf("created tag %s has object type %q, want tag", tag, strings.TrimSpace(tagType))
|
return "", fmt.Errorf("created tag %s has object type %q, want tag", ref, strings.TrimSpace(tagType))
|
||||||
}
|
}
|
||||||
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read created tag object %s: %w", tag, err)
|
return "", fmt.Errorf("read created tag object %s: %w", ref, err)
|
||||||
}
|
}
|
||||||
tagObject = strings.TrimSpace(tagObject)
|
tagObject = strings.TrimSpace(tagObject)
|
||||||
if !validObjectID(tagObject) {
|
if !validObjectID(tagObject) {
|
||||||
return "", fmt.Errorf("created tag %s has invalid object ID %q", tag, tagObject)
|
return "", fmt.Errorf("created tag %s has invalid object ID %q", ref, tagObject)
|
||||||
}
|
}
|
||||||
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
|
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read peeled commit for tag %s: %w", tag, err)
|
return tagObject, fmt.Errorf("read peeled commit for tag %s: %w", ref, err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(peeled) != commit {
|
if strings.TrimSpace(peeled) != commit {
|
||||||
return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", tag, strings.TrimSpace(peeled), commit)
|
return tagObject, fmt.Errorf("created tag %s peels to %s, want candidate %s", ref, strings.TrimSpace(peeled), commit)
|
||||||
}
|
}
|
||||||
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
|
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read annotated tag %s: %w", tag, err)
|
return tagObject, fmt.Errorf("read annotated tag %s: %w", ref, err)
|
||||||
}
|
}
|
||||||
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
|
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
|
||||||
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
|
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
|
||||||
return "", fmt.Errorf("annotated tag %s message does not match the captured release note", tag)
|
return tagObject, fmt.Errorf("annotated tag %s message does not match the captured release note", ref)
|
||||||
}
|
}
|
||||||
return tagObject, nil
|
return tagObject, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InspectLocalTag confirms the selected tag still points to the exact
|
|
||||||
// annotated tag object captured before publication.
|
|
||||||
func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error {
|
|
||||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !validObjectID(expectedObject) {
|
|
||||||
return fmt.Errorf("invalid expected tag object identity %q at %q", expectedObject, tag)
|
|
||||||
}
|
|
||||||
ref := "refs/tags/" + tag
|
|
||||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("inspect local tag %s: %w", tag, err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(tagType) != "tag" {
|
|
||||||
return fmt.Errorf("local tag %s has type %q, want annotated tag", tag, strings.TrimSpace(tagType))
|
|
||||||
}
|
|
||||||
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read local tag %s: %w", tag, err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(object) != expectedObject {
|
|
||||||
return fmt.Errorf("local tag %s identifies %s, want %s", tag, strings.TrimSpace(object), expectedObject)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryTag verifies the captured endpoint still resolves to itself, then reads
|
// QueryTag verifies the captured endpoint still resolves to itself, then reads
|
||||||
// one exact authoritative version tag and its peeled commit. Failed
|
// one exact authoritative version tag and its peeled commit. Failed
|
||||||
// verification or reads are unreadable and never interpreted as absence.
|
// verification or reads are unreadable and never interpreted as absence.
|
||||||
@@ -109,13 +100,13 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string
|
|||||||
peeledRef := ref + "^{}"
|
peeledRef := ref + "^{}"
|
||||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, endpoint.PushURL, err)
|
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, DisplayURL(endpoint.PushURL), err)
|
||||||
}
|
}
|
||||||
remoteTag := RemoteRef{State: RefAbsent}
|
remoteTag := RemoteRef{State: RefAbsent}
|
||||||
for _, line := range outputLines(output) {
|
for _, line := range outputLines(output) {
|
||||||
object, name, ok := strings.Cut(line, "\t")
|
object, name, ok := strings.Cut(line, "\t")
|
||||||
if !ok || !validObjectID(object) {
|
if !ok || !validObjectID(object) {
|
||||||
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
|
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, displayDiagnostic(line))
|
||||||
}
|
}
|
||||||
switch name {
|
switch name {
|
||||||
case ref:
|
case ref:
|
||||||
@@ -136,8 +127,8 @@ func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObj
|
|||||||
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
|
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
|
||||||
}
|
}
|
||||||
refspec := tagObject + ":refs/tags/" + tag
|
refspec := tagObject + ":refs/tags/" + tag
|
||||||
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--porcelain", endpoint.ScopedName, refspec); err != nil {
|
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--recurse-submodules=no", "--porcelain", endpoint.ScopedName, refspec); err != nil {
|
||||||
return fmt.Errorf("push annotated tag %s to %s: %w", tag, endpoint.PushURL, err)
|
return fmt.Errorf("push annotated tag %s to %s: %w", tag, DisplayURL(endpoint.PushURL), err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.UR
|
|||||||
}
|
}
|
||||||
assetURL.Fragment = ""
|
assetURL.Fragment = ""
|
||||||
visited := make(map[string]struct{})
|
visited := make(map[string]struct{})
|
||||||
|
followed := 0
|
||||||
for {
|
for {
|
||||||
key := assetURL.String()
|
key := assetURL.String()
|
||||||
if _, exists := visited[key]; exists {
|
if _, exists := visited[key]; exists {
|
||||||
@@ -75,6 +76,10 @@ func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.UR
|
|||||||
return nil, failure(FailureUnverifiable, "download release asset", 0, err)
|
return nil, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||||
}
|
}
|
||||||
if isRedirect(response.StatusCode) {
|
if isRedirect(response.StatusCode) {
|
||||||
|
if followed == 10 {
|
||||||
|
_ = response.Body.Close()
|
||||||
|
return nil, failure(FailureUnverifiable, "redirect release asset: redirect limit exceeded", response.StatusCode, errors.New("at most 10 redirects are allowed"))
|
||||||
|
}
|
||||||
location, locationErr := response.Location()
|
location, locationErr := response.Location()
|
||||||
_ = response.Body.Close()
|
_ = response.Body.Close()
|
||||||
if locationErr != nil || location.User != nil {
|
if locationErr != nil || location.User != nil {
|
||||||
@@ -82,6 +87,7 @@ func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.UR
|
|||||||
}
|
}
|
||||||
location.Fragment = ""
|
location.Fragment = ""
|
||||||
assetURL = location
|
assetURL = location
|
||||||
|
followed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
|||||||
@@ -4,15 +4,104 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.maximumdirect.net/releasekit/internal/forge"
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestBackendOpenAssetBoundsRedirects(t *testing.T) {
|
||||||
|
for _, hops := range []int{0, 10, 11} {
|
||||||
|
t.Run(strconv.Itoa(hops), func(t *testing.T) {
|
||||||
|
var requests atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
|
||||||
|
t.Errorf("authorization = %q", got)
|
||||||
|
}
|
||||||
|
step, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/"))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("invalid path %q", r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if step < hops {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/%d", step+1), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "asset")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
stream, err := backend.OpenAsset(t.Context(), assetHandle{downloadURL: "/0"})
|
||||||
|
if hops <= 10 {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(stream)
|
||||||
|
closeErr := stream.Close()
|
||||||
|
if readErr != nil || closeErr != nil || string(body) != "asset" || requests.Load() != int32(hops+1) {
|
||||||
|
t.Fatalf("body = %q, read = %v, close = %v, requests = %d", body, readErr, closeErr, requests.Load())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "redirect limit") || requests.Load() != 11 || stream != nil {
|
||||||
|
t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendOpenAssetRedirectReturnsToOrigin(t *testing.T) {
|
||||||
|
var origin *httptest.Server
|
||||||
|
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Errorf("cross-origin authorization = %q", got)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, origin.URL+"/final", http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer external.Close()
|
||||||
|
origin = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
|
||||||
|
t.Errorf("origin authorization = %q", got)
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/start" {
|
||||||
|
http.Redirect(w, r, external.URL+"/away", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "asset")
|
||||||
|
}))
|
||||||
|
defer origin.Close()
|
||||||
|
stream, err := NewBackend(testDestination(origin.URL), origin.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/start"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
if body, err := io.ReadAll(stream); err != nil || string(body) != "asset" {
|
||||||
|
t.Fatalf("body = %q, error = %v", body, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendOpenAssetRejectsRedirectCycle(t *testing.T) {
|
||||||
|
var requests atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
http.Redirect(w, r, "/loop#different-fragment", http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
stream, err := NewBackend(testDestination(server.URL), server.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/loop"})
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "loop") || requests.Load() != 1 || stream != nil {
|
||||||
|
t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) {
|
func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/repos/owner/project/releases/10/assets" {
|
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/repos/owner/project/releases/10/assets" {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func New(ctx context.Context, directory string) (*Tool, error) {
|
|||||||
// local Go toolchain. It preserves caller module-network settings and caches,
|
// local Go toolchain. It preserves caller module-network settings and caches,
|
||||||
// while disabling workspace discovery and automatic toolchain downloads.
|
// while disabling workspace discovery and automatic toolchain downloads.
|
||||||
func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
||||||
captured := &combinedWriter{}
|
captured := &combinedWriter{mode: captureStatus}
|
||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: []string{"go", "mod", "download", "-modfile="},
|
Args: []string{"go", "mod", "download", "-modfile="},
|
||||||
Dir: directory,
|
Dir: directory,
|
||||||
@@ -107,7 +107,8 @@ func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
|||||||
Stderr: captured,
|
Stderr: captured,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if output := strings.TrimSpace(string(captured.take())); output != "" {
|
omitted := captured.omitted()
|
||||||
|
if output := diagnosticText(captured.take(), omitted); output != "" {
|
||||||
return fmt.Errorf("go mod download: %w: %s", err, output)
|
return fmt.Errorf("go mod download: %w: %s", err, output)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("go mod download: %w", err)
|
return fmt.Errorf("go mod download: %w", err)
|
||||||
@@ -140,7 +141,11 @@ func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, targ
|
|||||||
return nil, errors.New("Go command requires arguments")
|
return nil, errors.New("Go command requires arguments")
|
||||||
}
|
}
|
||||||
environment := tool.environment(mode, target)
|
environment := tool.environment(mode, target)
|
||||||
captured := &combinedWriter{output: output}
|
modeCapture := captureStatus
|
||||||
|
if keepOutput {
|
||||||
|
modeCapture = captureFull
|
||||||
|
}
|
||||||
|
captured := &combinedWriter{output: output, mode: modeCapture}
|
||||||
commandArgs := append([]string{"go"}, args...)
|
commandArgs := append([]string{"go"}, args...)
|
||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: commandArgs,
|
Args: commandArgs,
|
||||||
@@ -152,16 +157,28 @@ func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, targ
|
|||||||
if forwardErr := captured.forwardingError(); forwardErr != nil {
|
if forwardErr := captured.forwardingError(); forwardErr != nil {
|
||||||
err = errors.Join(err, fmt.Errorf("forward Go output: %w", forwardErr))
|
err = errors.Join(err, fmt.Errorf("forward Go output: %w", forwardErr))
|
||||||
}
|
}
|
||||||
|
if err == nil && !keepOutput {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
omitted := captured.omitted()
|
||||||
contents := captured.take()
|
contents := captured.take()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if len(contents) != 0 {
|
if diagnostic := diagnosticText(contents, omitted); diagnostic != "" {
|
||||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
|
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, diagnostic)
|
||||||
}
|
}
|
||||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
|
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
|
||||||
}
|
}
|
||||||
return outputBytes(contents, keepOutput), nil
|
return outputBytes(contents, keepOutput), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func diagnosticText(contents []byte, omitted bool) string {
|
||||||
|
text := strings.TrimSpace(string(contents))
|
||||||
|
if omitted {
|
||||||
|
return "[earlier Go output omitted]\n" + text
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
func outputBytes(contents []byte, keep bool) []byte {
|
func outputBytes(contents []byte, keep bool) []byte {
|
||||||
if keep {
|
if keep {
|
||||||
return contents
|
return contents
|
||||||
@@ -232,17 +249,63 @@ type combinedWriter struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
output io.Writer
|
output io.Writer
|
||||||
buffer bytes.Buffer
|
buffer bytes.Buffer
|
||||||
|
mode captureMode
|
||||||
|
tail []byte
|
||||||
|
head int
|
||||||
|
truncated bool
|
||||||
forwardErr error
|
forwardErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type captureMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
captureFull captureMode = iota
|
||||||
|
captureStatus
|
||||||
|
statusDiagnosticLimit = 1 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
func (writer *combinedWriter) capture(contents []byte) {
|
||||||
|
if writer.mode == captureFull {
|
||||||
|
_, _ = writer.buffer.Write(contents)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(contents) >= statusDiagnosticLimit {
|
||||||
|
writer.truncated = writer.truncated || len(writer.tail) != 0 || len(contents) > statusDiagnosticLimit
|
||||||
|
if cap(writer.tail) < statusDiagnosticLimit {
|
||||||
|
writer.tail = make([]byte, statusDiagnosticLimit)
|
||||||
|
} else {
|
||||||
|
writer.tail = writer.tail[:statusDiagnosticLimit]
|
||||||
|
}
|
||||||
|
copy(writer.tail, contents[len(contents)-statusDiagnosticLimit:])
|
||||||
|
writer.head = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if available := statusDiagnosticLimit - len(writer.tail); available > 0 {
|
||||||
|
n := min(available, len(contents))
|
||||||
|
if needed := len(writer.tail) + n; needed > cap(writer.tail) {
|
||||||
|
capacity := min(statusDiagnosticLimit, max(needed, 2*cap(writer.tail)))
|
||||||
|
grown := make([]byte, len(writer.tail), capacity)
|
||||||
|
copy(grown, writer.tail)
|
||||||
|
writer.tail = grown
|
||||||
|
}
|
||||||
|
writer.tail = append(writer.tail, contents[:n]...)
|
||||||
|
contents = contents[n:]
|
||||||
|
}
|
||||||
|
if len(contents) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.truncated = true
|
||||||
|
n := copy(writer.tail[writer.head:], contents)
|
||||||
|
copy(writer.tail, contents[n:])
|
||||||
|
writer.head = (writer.head + len(contents)) % statusDiagnosticLimit
|
||||||
|
}
|
||||||
|
|
||||||
func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
||||||
writer.mu.Lock()
|
writer.mu.Lock()
|
||||||
defer writer.mu.Unlock()
|
defer writer.mu.Unlock()
|
||||||
|
writer.capture(contents)
|
||||||
if writer.output == nil {
|
if writer.output == nil {
|
||||||
return writer.buffer.Write(contents)
|
return len(contents), nil
|
||||||
}
|
|
||||||
if _, err := writer.buffer.Write(contents); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
written, err := writer.output.Write(contents)
|
written, err := writer.output.Write(contents)
|
||||||
if err == nil && written != len(contents) {
|
if err == nil && written != len(contents) {
|
||||||
@@ -254,6 +317,12 @@ func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
|||||||
return written, err
|
return written, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (writer *combinedWriter) omitted() bool {
|
||||||
|
writer.mu.Lock()
|
||||||
|
defer writer.mu.Unlock()
|
||||||
|
return writer.truncated
|
||||||
|
}
|
||||||
|
|
||||||
func (writer *combinedWriter) forwardingError() error {
|
func (writer *combinedWriter) forwardingError() error {
|
||||||
writer.mu.Lock()
|
writer.mu.Lock()
|
||||||
defer writer.mu.Unlock()
|
defer writer.mu.Unlock()
|
||||||
@@ -265,5 +334,18 @@ func (writer *combinedWriter) forwardingError() error {
|
|||||||
func (writer *combinedWriter) take() []byte {
|
func (writer *combinedWriter) take() []byte {
|
||||||
writer.mu.Lock()
|
writer.mu.Lock()
|
||||||
defer writer.mu.Unlock()
|
defer writer.mu.Unlock()
|
||||||
return writer.buffer.Bytes()
|
if writer.mode == captureFull {
|
||||||
|
contents := writer.buffer.Bytes()
|
||||||
|
writer.buffer = bytes.Buffer{}
|
||||||
|
return contents
|
||||||
|
}
|
||||||
|
contents := writer.tail
|
||||||
|
if writer.head != 0 {
|
||||||
|
contents = make([]byte, 0, len(writer.tail))
|
||||||
|
contents = append(contents, writer.tail[writer.head:]...)
|
||||||
|
contents = append(contents, writer.tail[:writer.head]...)
|
||||||
|
}
|
||||||
|
writer.tail = nil
|
||||||
|
writer.head = 0
|
||||||
|
return contents
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -453,6 +455,96 @@ func TestOutputReturnsLargeFailureDiagnosticsAndCause(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatusCaptureRetainsTailAndForwardsAll(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
size int
|
||||||
|
chunkSize int
|
||||||
|
}{
|
||||||
|
{"below", statusDiagnosticLimit - 1, 8191},
|
||||||
|
{"exact", statusDiagnosticLimit, 8191},
|
||||||
|
{"above", statusDiagnosticLimit + 1, 8191},
|
||||||
|
{"single oversized", statusDiagnosticLimit*2 + 3, statusDiagnosticLimit*2 + 3},
|
||||||
|
{"wrapped small writes", statusDiagnosticLimit*3 + 17, 113},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
input := make([]byte, test.size)
|
||||||
|
for i := range input {
|
||||||
|
input[i] = byte(i % 251)
|
||||||
|
}
|
||||||
|
var forwarded bytes.Buffer
|
||||||
|
writer := &combinedWriter{mode: captureStatus, output: &forwarded}
|
||||||
|
for start := 0; start < len(input); start += test.chunkSize {
|
||||||
|
chunk := input[start:min(start+test.chunkSize, len(input))]
|
||||||
|
if n, err := writer.Write(chunk); n != len(chunk) || err != nil {
|
||||||
|
t.Fatalf("write = %d, %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if writer.omitted() != (len(input) > statusDiagnosticLimit) || cap(writer.tail) > statusDiagnosticLimit {
|
||||||
|
t.Fatalf("omitted = %t, capacity = %d", writer.omitted(), cap(writer.tail))
|
||||||
|
}
|
||||||
|
want := input[max(0, len(input)-statusDiagnosticLimit):]
|
||||||
|
if got := writer.take(); !bytes.Equal(got, want) || !bytes.Equal(forwarded.Bytes(), input) {
|
||||||
|
t.Fatalf("capture size = %d, forwarded size = %d", len(got), forwarded.Len())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusCaptureSerializesConcurrentWriters(t *testing.T) {
|
||||||
|
var forwarded bytes.Buffer
|
||||||
|
writer := &combinedWriter{mode: captureStatus, output: &forwarded}
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for _, value := range []byte{'a', 'b'} {
|
||||||
|
group.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
chunk := bytes.Repeat([]byte{value}, 4096)
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
_, _ = writer.Write(chunk)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
group.Wait()
|
||||||
|
contents := forwarded.Bytes()
|
||||||
|
if !writer.omitted() || !bytes.Equal(writer.take(), contents[len(contents)-statusDiagnosticLimit:]) {
|
||||||
|
t.Fatal("concurrent capture differs from forwarded tail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusCommandsRetainBoundedFailureDiagnostics(t *testing.T) {
|
||||||
|
repository, _ := installGoPreparationFixture(t)
|
||||||
|
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "large-fail")
|
||||||
|
var forwarded bytes.Buffer
|
||||||
|
err := fixtureTool().Run(context.Background(), repository, Native, Target{}, &forwarded, "version")
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "earlier Go output omitted") || !strings.Contains(err.Error(), "fixture large failure") || len(err.Error()) > statusDiagnosticLimit+200 || forwarded.Len() != (8<<20)+len("fixture large failure\n") {
|
||||||
|
t.Fatalf("Run() error = %v, forwarded = %d bytes", err, forwarded.Len())
|
||||||
|
}
|
||||||
|
err = PrepareBuildDependencies(context.Background(), repository)
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "earlier Go output omitted") || !strings.Contains(err.Error(), "fixture large failure") || len(err.Error()) > statusDiagnosticLimit+200 {
|
||||||
|
t.Fatalf("preparation error did not retain bounded diagnostics: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusCommandPreservesForwardingError(t *testing.T) {
|
||||||
|
repository, _ := installGoPreparationFixture(t)
|
||||||
|
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output")
|
||||||
|
writerErr := errors.New("forwarding failed")
|
||||||
|
err := fixtureTool().Run(context.Background(), repository, Native, Target{}, failingOutputWriter{err: writerErr}, "version")
|
||||||
|
if !errors.Is(err, writerErr) {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
err = fixtureTool().Run(context.Background(), repository, Native, Target{}, shortOutputWriter{}, "version")
|
||||||
|
if !errors.Is(err, io.ErrShortWrite) {
|
||||||
|
t.Fatalf("Run() short-write error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type shortOutputWriter struct{}
|
||||||
|
|
||||||
|
func (shortOutputWriter) Write([]byte) (int, error) { return 0, nil }
|
||||||
|
|
||||||
func TestOutputReturnsCancellation(t *testing.T) {
|
func TestOutputReturnsCancellation(t *testing.T) {
|
||||||
repository, recordPath := installGoPreparationFixture(t)
|
repository, recordPath := installGoPreparationFixture(t)
|
||||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait")
|
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait")
|
||||||
|
|||||||
@@ -20,25 +20,33 @@ func BenchmarkPerformanceCombinedOutput(b *testing.B) {
|
|||||||
} {
|
} {
|
||||||
input := bytes.Repeat([]byte("go tool diagnostic output\n"), (testCase.size+25)/26)
|
input := bytes.Repeat([]byte("go tool diagnostic output\n"), (testCase.size+25)/26)
|
||||||
input = input[:testCase.size]
|
input = input[:testCase.size]
|
||||||
b.Run(testCase.name+"/discard-forwarding", func(b *testing.B) {
|
for _, mode := range []struct {
|
||||||
|
name string
|
||||||
|
mode captureMode
|
||||||
|
}{{"full", captureFull}, {"status", captureStatus}} {
|
||||||
|
b.Run(testCase.name+"/"+mode.name, func(b *testing.B) {
|
||||||
// Verify capture and forwarding for this workload outside the measurement.
|
// Verify capture and forwarding for this workload outside the measurement.
|
||||||
var forwarded bytes.Buffer
|
var forwarded bytes.Buffer
|
||||||
verification := &combinedWriter{output: &forwarded}
|
verification := &combinedWriter{output: &forwarded, mode: mode.mode}
|
||||||
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
||||||
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
||||||
if written, err := verification.Write(chunk); err != nil || written != len(chunk) {
|
if written, err := verification.Write(chunk); err != nil || written != len(chunk) {
|
||||||
b.Fatalf("verify output write = %d, err %v; want %d bytes", written, err, len(chunk))
|
b.Fatalf("verify output write = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !bytes.Equal(verification.take(), input) || !bytes.Equal(forwarded.Bytes(), input) {
|
want := input
|
||||||
b.Fatal("captured and forwarded output must equal the complete input")
|
if mode.mode == captureStatus && len(want) > statusDiagnosticLimit {
|
||||||
|
want = want[len(want)-statusDiagnosticLimit:]
|
||||||
|
}
|
||||||
|
if !bytes.Equal(verification.take(), want) || !bytes.Equal(forwarded.Bytes(), input) {
|
||||||
|
b.Fatal("captured output or forwarding did not match the selected mode")
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
b.SetBytes(int64(len(input)))
|
b.SetBytes(int64(len(input)))
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
b.ReportMetric(float64(len(input)), "bytes/op")
|
b.ReportMetric(float64(len(input)), "bytes/op")
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
writer := &combinedWriter{output: io.Discard}
|
writer := &combinedWriter{output: io.Discard, mode: mode.mode}
|
||||||
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
||||||
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
||||||
if written, err := writer.Write(chunk); err != nil || written != len(chunk) {
|
if written, err := writer.Write(chunk); err != nil || written != len(chunk) {
|
||||||
@@ -46,10 +54,11 @@ func BenchmarkPerformanceCombinedOutput(b *testing.B) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
captured := writer.take()
|
captured := writer.take()
|
||||||
if len(captured) != len(input) {
|
if len(captured) != len(want) {
|
||||||
b.Fatalf("captured output = %d bytes, want %d", len(captured), len(input))
|
b.Fatalf("captured output = %d bytes, want %d", len(captured), len(want))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Invocation describes one child process. UnsetEnv removes inherited values
|
// Invocation describes one child process. UnsetEnv removes inherited values
|
||||||
@@ -54,6 +54,8 @@ type PipedProcess struct {
|
|||||||
stdout io.ReadCloser
|
stdout io.ReadCloser
|
||||||
parent context.Context
|
parent context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
stopIO func() bool
|
||||||
|
ioDone <-chan struct{}
|
||||||
waitOnce sync.Once
|
waitOnce sync.Once
|
||||||
waitErr error
|
waitErr error
|
||||||
}
|
}
|
||||||
@@ -79,6 +81,9 @@ func (child *PipedProcess) Cancel() {
|
|||||||
func (child *PipedProcess) Wait() error {
|
func (child *PipedProcess) Wait() error {
|
||||||
child.waitOnce.Do(func() {
|
child.waitOnce.Do(func() {
|
||||||
err := child.command.Wait()
|
err := child.command.Wait()
|
||||||
|
if !child.stopIO() {
|
||||||
|
<-child.ioDone
|
||||||
|
}
|
||||||
child.cancel()
|
child.cancel()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
@@ -120,12 +125,20 @@ func StartPiped(ctx context.Context, invocation Invocation) (*PipedProcess, erro
|
|||||||
cancel()
|
cancel()
|
||||||
return nil, fmt.Errorf("start %q: %w", invocation.Args[0], err)
|
return nil, fmt.Errorf("start %q: %w", invocation.Args[0], err)
|
||||||
}
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
stopIO := context.AfterFunc(childContext, func() {
|
||||||
|
defer close(done)
|
||||||
|
_ = stdin.Close()
|
||||||
|
_ = stdout.Close()
|
||||||
|
})
|
||||||
return &PipedProcess{
|
return &PipedProcess{
|
||||||
command: command,
|
command: command,
|
||||||
stdin: stdin,
|
stdin: stdin,
|
||||||
stdout: stdout,
|
stdout: stdout,
|
||||||
parent: ctx,
|
parent: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
|
stopIO: stopIO,
|
||||||
|
ioDone: done,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,15 +148,17 @@ func newCommand(ctx context.Context, invocation Invocation) (*exec.Cmd, error) {
|
|||||||
}
|
}
|
||||||
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
||||||
command.Dir = invocation.Dir
|
command.Dir = invocation.Dir
|
||||||
|
command.WaitDelay = time.Second
|
||||||
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
||||||
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
|
// Dir contributes PWD on POSIX before explicit overrides are applied.
|
||||||
|
command.Env = environmentEntries(command.Environ(), invocation.Env, invocation.UnsetEnv)
|
||||||
}
|
}
|
||||||
return command, nil
|
return command, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func environmentEntries(overlay map[string]string, unset []string) []string {
|
func environmentEntries(base []string, overlay map[string]string, unset []string) []string {
|
||||||
environment := make(map[string]string, len(os.Environ())+len(overlay))
|
environment := make(map[string]string, len(base)+len(overlay))
|
||||||
for _, entry := range os.Environ() {
|
for _, entry := range base {
|
||||||
key, value, ok := strings.Cut(entry, "=")
|
key, value, ok := strings.Cut(entry, "=")
|
||||||
if ok {
|
if ok {
|
||||||
environment[key] = value
|
environment[key] = value
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -78,6 +80,50 @@ func TestRunCanRemoveInheritedEnvironment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunDirectoryEnvironmentPreservesExplicitPWDSelection(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("directory-derived PWD is a POSIX behavior")
|
||||||
|
}
|
||||||
|
parent := mustGetwd(t)
|
||||||
|
t.Setenv("PWD", parent)
|
||||||
|
workdir := t.TempDir()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, override, want string
|
||||||
|
unset bool
|
||||||
|
present bool
|
||||||
|
}{
|
||||||
|
{name: "directory", want: workdir, present: true},
|
||||||
|
{name: "override", override: "selected PWD", want: "selected PWD", present: true},
|
||||||
|
{name: "removed", unset: true},
|
||||||
|
{name: "override after removal", unset: true, override: "selected PWD", want: "selected PWD", present: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
invocation := Invocation{
|
||||||
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "pwd"},
|
||||||
|
Dir: workdir,
|
||||||
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
||||||
|
}
|
||||||
|
if test.override != "" {
|
||||||
|
invocation.Env["PWD"] = test.override
|
||||||
|
}
|
||||||
|
if test.unset {
|
||||||
|
invocation.UnsetEnv = []string{"PWD"}
|
||||||
|
}
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
invocation.Stdout = &stdout
|
||||||
|
if err := Run(t.Context(), invocation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := stdout.String(), fmt.Sprintf("%s|%t", test.want, test.present); got != want {
|
||||||
|
t.Fatalf("child PWD = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if os.Getenv("PWD") != parent || mustGetwd(t) != parent {
|
||||||
|
t.Fatal("child invocation changed the parent's directory or PWD")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunReportsMissingExecutable(t *testing.T) {
|
func TestRunReportsMissingExecutable(t *testing.T) {
|
||||||
err := Run(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
|
err := Run(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
|
||||||
if err == nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
|
if err == nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
|
||||||
@@ -119,6 +165,68 @@ func TestRunReturnsCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunBoundsInheritedOutputPipes(t *testing.T) {
|
||||||
|
for _, mode := range []string{"cancel-descendant", "exit-descendant"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
ready := filepath.Join(t.TempDir(), "descendant")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
var output bytes.Buffer
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
result <- Run(ctx, Invocation{
|
||||||
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", mode},
|
||||||
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1", "RELEASEKIT_DESCENDANT_READY": ready},
|
||||||
|
Stdout: &output, Stderr: &output,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
deadline := time.After(10 * time.Second)
|
||||||
|
var pid int
|
||||||
|
for pid == 0 {
|
||||||
|
data, err := os.ReadFile(ready)
|
||||||
|
if err == nil {
|
||||||
|
pid, _ = strconv.Atoi(string(data))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("descendant did not become ready")
|
||||||
|
default:
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if process, err := os.FindProcess(pid); err == nil {
|
||||||
|
_ = process.Kill()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if mode == "cancel-descendant" {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if mode == "cancel-descendant" && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled command error = %v", err)
|
||||||
|
}
|
||||||
|
if mode == "exit-descendant" && !errors.Is(err, exec.ErrWaitDelay) {
|
||||||
|
t.Fatalf("exited command error = %v, want pipe teardown error", err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("command did not release inherited output pipes")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDoesNotLimitHealthyCommandLifetime(t *testing.T) {
|
||||||
|
err := Run(context.Background(), Invocation{
|
||||||
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "slow-success"},
|
||||||
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("healthy command failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
|
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
|
||||||
workdir := filepath.Join(t.TempDir(), "piped directory")
|
workdir := filepath.Join(t.TempDir(), "piped directory")
|
||||||
if err := os.Mkdir(workdir, 0o700); err != nil {
|
if err := os.Mkdir(workdir, 0o700); err != nil {
|
||||||
@@ -197,6 +305,52 @@ func TestStartPipedCancellationAfterReadinessReapsChild(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartPipedCancellationInterruptsInheritedOutputDrain(t *testing.T) {
|
||||||
|
ready := filepath.Join(t.TempDir(), "descendant")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
child, err := StartPiped(ctx, Invocation{
|
||||||
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit-descendant"},
|
||||||
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1", "RELEASEKIT_DESCENDANT_READY": ready},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = child.Stdin().Close()
|
||||||
|
drained := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(io.Discard, child.Stdout())
|
||||||
|
drained <- err
|
||||||
|
}()
|
||||||
|
deadline := time.After(10 * time.Second)
|
||||||
|
var pid int
|
||||||
|
for pid == 0 {
|
||||||
|
if data, err := os.ReadFile(ready); err == nil {
|
||||||
|
pid, _ = strconv.Atoi(string(data))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("descendant did not become ready")
|
||||||
|
default:
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if descendant, err := os.FindProcess(pid); err == nil {
|
||||||
|
_ = descendant.Kill()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-drained:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("inherited stdout did not close after cancellation")
|
||||||
|
}
|
||||||
|
if err := child.Wait(); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("wait after cancellation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartPipedCancelDoesNotCancelParentContext(t *testing.T) {
|
func TestStartPipedCancelDoesNotCancelParentContext(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
child, err := StartPiped(ctx, Invocation{
|
child, err := StartPiped(ctx, Invocation{
|
||||||
@@ -269,6 +423,10 @@ func TestProcessHelper(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mode := args[len(args)-1]
|
mode := args[len(args)-1]
|
||||||
switch mode {
|
switch mode {
|
||||||
|
case "pwd":
|
||||||
|
value, present := os.LookupEnv("PWD")
|
||||||
|
fmt.Fprintf(os.Stdout, "%s|%t", value, present)
|
||||||
|
os.Exit(0)
|
||||||
case "success":
|
case "success":
|
||||||
workdir, err := os.Getwd()
|
workdir, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -279,11 +437,39 @@ func TestProcessHelper(t *testing.T) {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
case "exit":
|
case "exit":
|
||||||
os.Exit(7)
|
os.Exit(7)
|
||||||
|
case "slow-success":
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
os.Exit(0)
|
||||||
case "wait":
|
case "wait":
|
||||||
fmt.Fprintln(os.Stdout, "started")
|
fmt.Fprintln(os.Stdout, "started")
|
||||||
for {
|
for {
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
}
|
}
|
||||||
|
case "cancel-descendant", "exit-descendant":
|
||||||
|
child := exec.Command(os.Args[0], "-test.run=TestProcessHelper", "--", "unused", "descendant")
|
||||||
|
child.Stdout, child.Stderr = os.Stdout, os.Stderr
|
||||||
|
if err := child.Start(); err != nil {
|
||||||
|
os.Exit(14)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(os.Getenv("RELEASEKIT_DESCENDANT_READY")); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if mode == "exit-descendant" {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
case "descendant":
|
||||||
|
if err := os.WriteFile(os.Getenv("RELEASEKIT_DESCENDANT_READY"), []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
|
||||||
|
os.Exit(15)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
case "exchange":
|
case "exchange":
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
request, err := reader.ReadString('\n')
|
request, err := reader.ReadString('\n')
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
|||||||
}
|
}
|
||||||
result.Endpoint = endpoint.PushURL
|
result.Endpoint = endpoint.PushURL
|
||||||
if request.ExpectedPushURL != "" && request.ExpectedPushURL != endpoint.PushURL {
|
if request.ExpectedPushURL != "" && request.ExpectedPushURL != endpoint.PushURL {
|
||||||
return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, request.ExpectedPushURL, endpoint.PushURL)
|
return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, git.DisplayURL(request.ExpectedPushURL), git.DisplayURL(endpoint.PushURL))
|
||||||
}
|
}
|
||||||
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
|
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
|
||||||
return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
|
return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
|
||||||
@@ -147,10 +147,10 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
|||||||
result.Phase = TagCreation
|
result.Phase = TagCreation
|
||||||
result.State = NotPublished
|
result.State = NotPublished
|
||||||
tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note))
|
tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note))
|
||||||
|
result.TagObject = tagObject
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
result.TagObject = tagObject
|
|
||||||
if err := candidate.Recheck(ctx, captured, tagObject); err != nil {
|
if err := candidate.Recheck(ctx, captured, tagObject); err != nil {
|
||||||
return result, fmt.Errorf("pre-push local guard: %w", err)
|
return result, fmt.Errorf("pre-push local guard: %w", err)
|
||||||
}
|
}
|
||||||
@@ -188,11 +188,11 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
|
|||||||
queryErr = git.ErrRemoteStateUnreadable
|
queryErr = git.ErrRemoteStateUnreadable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, endpoint.PushURL, queryErr, pushErr)
|
return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, git.DisplayURL(endpoint.PushURL), queryErr, pushErr)
|
||||||
}
|
}
|
||||||
if remoteTag.State == git.RefAbsent {
|
if remoteTag.State == git.RefAbsent {
|
||||||
result.State = NotPublished
|
result.State = NotPublished
|
||||||
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, endpoint.PushURL, pushErr)
|
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, git.DisplayURL(endpoint.PushURL), pushErr)
|
||||||
}
|
}
|
||||||
if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
|
if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
|
||||||
result.Phase = Complete
|
result.Phase = Complete
|
||||||
@@ -200,7 +200,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
result.State = Conflict
|
result.State = Conflict
|
||||||
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s at %s: %w", captured.Tag, remoteTag.Object, tagObject, endpoint.PushURL, pushErr)
|
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s at %s: %w", captured.Tag, remoteTag.Object, tagObject, git.DisplayURL(endpoint.PushURL), pushErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isRemoteConflict(err error) bool {
|
func isRemoteConflict(err error) bool {
|
||||||
|
|||||||
@@ -126,12 +126,6 @@ func selectCommittedDirectory(ctx context.Context, resolver git.PathResolver, mo
|
|||||||
return selection, nil
|
return selection, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromCommittedSelection derives identity from a previously resolved module
|
|
||||||
// selection and the selected module declaration at a captured commit.
|
|
||||||
func FromCommittedSelection(ctx context.Context, selection Selection, version, commit string) (Identity, error) {
|
|
||||||
return fromCommittedSelection(ctx, selection, version, commit, git.CommittedPaths(selection.RepositoryDir, commit))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fromCommittedSelection(ctx context.Context, selection Selection, version, commit string, resolver git.PathResolver) (Identity, error) {
|
func fromCommittedSelection(ctx context.Context, selection Selection, version, commit string, resolver git.PathResolver) (Identity, error) {
|
||||||
identity := Identity{Selection: selection, Version: version}
|
identity := Identity{Selection: selection, Version: version}
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package tempdir
|
package tempdir
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,11 +12,15 @@ import (
|
|||||||
// Outside uses system temporary storage and falls back to the repository's
|
// Outside uses system temporary storage and falls back to the repository's
|
||||||
// parent when TMPDIR resolves inside the worktree. Cleanup belongs to the caller.
|
// parent when TMPDIR resolves inside the worktree. Cleanup belongs to the caller.
|
||||||
func Outside(repositoryDir, pattern string) (string, error) {
|
func Outside(repositoryDir, pattern string) (string, error) {
|
||||||
repository, err := filepath.Abs(repositoryDir)
|
return outside(repositoryDir, pattern, filepath.Abs, filepath.EvalSymlinks, os.RemoveAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func outside(repositoryDir, pattern string, abs, resolve func(string) (string, error), remove func(string) error) (string, error) {
|
||||||
|
repository, err := abs(repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
repository, err = filepath.EvalSymlinks(repository)
|
repository, err = resolve(repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -24,54 +29,54 @@ func Outside(repositoryDir, pattern string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
created := directory
|
created := directory
|
||||||
directory, err = filepath.Abs(created)
|
directory, err = abs(created)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(created)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
directory, err = filepath.EvalSymlinks(directory)
|
directory, err = resolve(directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(directory)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
outside, err := isOutside(repository, directory)
|
outside, err := isOutside(repository, directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(directory)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
if outside {
|
if outside {
|
||||||
return directory, nil
|
return directory, nil
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(directory); err != nil {
|
if err := remove(created); err != nil {
|
||||||
return "", fmt.Errorf("remove temporary storage inside repository %q: %w", repository, err)
|
return "", fmt.Errorf("remove temporary storage %q: %w", created, err)
|
||||||
}
|
}
|
||||||
directory, err = os.MkdirTemp(filepath.Dir(repository), pattern)
|
directory, err = os.MkdirTemp(filepath.Dir(repository), pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("create temporary storage outside repository %q: %w", repository, err)
|
return "", fmt.Errorf("create temporary storage outside repository %q: %w", repository, err)
|
||||||
}
|
}
|
||||||
created = directory
|
created = directory
|
||||||
directory, err = filepath.Abs(created)
|
directory, err = abs(created)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(created)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
directory, err = filepath.EvalSymlinks(directory)
|
directory, err = resolve(directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(directory)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
outside, err = isOutside(repository, directory)
|
outside, err = isOutside(repository, directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(directory)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
if !outside {
|
if !outside {
|
||||||
os.RemoveAll(directory)
|
return "", cleanupError(created, fmt.Errorf("temporary storage %q is inside repository %q", directory, repository), remove)
|
||||||
return "", fmt.Errorf("temporary storage %q is inside repository %q", directory, repository)
|
|
||||||
}
|
}
|
||||||
return directory, nil
|
return directory, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanupError(path string, cause error, remove func(string) error) error {
|
||||||
|
if err := remove(path); err != nil {
|
||||||
|
return errors.Join(cause, fmt.Errorf("remove temporary storage %q: %w", path, err))
|
||||||
|
}
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
|
||||||
func isOutside(repository, directory string) (bool, error) {
|
func isOutside(repository, directory string) (bool, error) {
|
||||||
if !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
|
if !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|||||||
@@ -1,12 +1,81 @@
|
|||||||
package tempdir
|
package tempdir
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestOutsideRemovesAllocatedPathWhenResolutionFails(t *testing.T) {
|
||||||
|
for _, fallback := range []bool{false, true} {
|
||||||
|
for _, operation := range []string{"absolute", "symlinks"} {
|
||||||
|
name := operation
|
||||||
|
if fallback {
|
||||||
|
name += "-fallback"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
parent := t.TempDir()
|
||||||
|
repository := filepath.Join(parent, "repository")
|
||||||
|
local := filepath.Join(repository, "tmp")
|
||||||
|
if err := os.MkdirAll(local, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fallback {
|
||||||
|
setTemporaryDirectory(t, local)
|
||||||
|
} else {
|
||||||
|
setTemporaryDirectory(t, parent)
|
||||||
|
}
|
||||||
|
resolutionErr := errors.New("resolution failed")
|
||||||
|
removalErr := errors.New("removal failed")
|
||||||
|
calls := 0
|
||||||
|
failed := false
|
||||||
|
var removed string
|
||||||
|
failAt := 2
|
||||||
|
if fallback {
|
||||||
|
failAt = 3
|
||||||
|
}
|
||||||
|
abs := func(path string) (string, error) {
|
||||||
|
if operation == "absolute" {
|
||||||
|
calls++
|
||||||
|
if calls == failAt {
|
||||||
|
failed = true
|
||||||
|
return "", resolutionErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Abs(path)
|
||||||
|
}
|
||||||
|
resolve := func(path string) (string, error) {
|
||||||
|
if operation == "symlinks" {
|
||||||
|
calls++
|
||||||
|
if calls == failAt {
|
||||||
|
failed = true
|
||||||
|
return "", resolutionErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.EvalSymlinks(path)
|
||||||
|
}
|
||||||
|
remove := func(path string) error {
|
||||||
|
if err := os.RemoveAll(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if failed {
|
||||||
|
removed = path
|
||||||
|
return removalErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := outside(repository, "owned-", abs, resolve, remove)
|
||||||
|
if !errors.Is(err, resolutionErr) || !errors.Is(err, removalErr) || removed == "" || !strings.Contains(err.Error(), removed) || !strings.HasPrefix(filepath.Base(removed), "owned-") {
|
||||||
|
t.Fatalf("error = %v, removed = %q", err, removed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOutsideHonorsRelativeTemporaryDirectory(t *testing.T) {
|
func TestOutsideHonorsRelativeTemporaryDirectory(t *testing.T) {
|
||||||
parent := t.TempDir()
|
parent := t.TempDir()
|
||||||
repository := t.TempDir()
|
repository := t.TempDir()
|
||||||
|
|||||||
109
internal/validation/file_size_benchmark_test.go
Normal file
109
internal/validation/file_size_benchmark_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// benchmarkExclusionIndex is an assessment-only alternative, not production logic.
|
||||||
|
func benchmarkExclusionIndex(exclusions []string) map[string]struct{} {
|
||||||
|
index := make(map[string]struct{}, len(exclusions))
|
||||||
|
for _, exclusion := range normalizedExclusions(exclusions) {
|
||||||
|
index[exclusion] = struct{}{}
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchmarkExcluded(filePath string, index map[string]struct{}) bool {
|
||||||
|
if _, ok := index["."]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := index[filePath]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for pos := 0; pos < len(filePath); pos++ {
|
||||||
|
if filePath[pos] == '/' {
|
||||||
|
if _, ok := index[filePath[:pos]]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var benchmarkExclusionMatches int
|
||||||
|
|
||||||
|
func BenchmarkFileSizeExclusions(b *testing.B) {
|
||||||
|
for _, pathCount := range []int{1000, 10000} {
|
||||||
|
for _, exclusionCount := range []int{1, 10, 100, 1000} {
|
||||||
|
for _, depth := range []string{"shallow", "deep"} {
|
||||||
|
for _, match := range []bool{true, false} {
|
||||||
|
exclusions := make([]string, exclusionCount)
|
||||||
|
for i := range exclusions {
|
||||||
|
exclusions[i] = fmt.Sprintf("excluded-%04d", i)
|
||||||
|
}
|
||||||
|
paths := make([]string, pathCount)
|
||||||
|
prefix := "other"
|
||||||
|
if match {
|
||||||
|
prefix = exclusions[0]
|
||||||
|
}
|
||||||
|
if depth == "deep" {
|
||||||
|
prefix += "/nested/one/two/three/four"
|
||||||
|
}
|
||||||
|
for i := range paths {
|
||||||
|
paths[i] = fmt.Sprintf("%s/file-%05d", prefix, i)
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("paths=%d/exclusions=%d/%s/match=%t", pathCount, exclusionCount, depth, match)
|
||||||
|
for _, indexed := range []bool{false, true} {
|
||||||
|
method := "scan"
|
||||||
|
if indexed {
|
||||||
|
method = "index"
|
||||||
|
}
|
||||||
|
b.Run(name+"/"+method, func(b *testing.B) {
|
||||||
|
normalized := normalizedExclusions(exclusions)
|
||||||
|
index := benchmarkExclusionIndex(exclusions)
|
||||||
|
for _, filePath := range paths {
|
||||||
|
if excludedByPath(filePath, normalized) != benchmarkExcluded(filePath, index) {
|
||||||
|
b.Fatalf("index disagrees for %q", filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
count := 0
|
||||||
|
if indexed {
|
||||||
|
index := benchmarkExclusionIndex(exclusions)
|
||||||
|
for _, filePath := range paths {
|
||||||
|
if benchmarkExcluded(filePath, index) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
normalized := normalizedExclusions(exclusions)
|
||||||
|
for _, filePath := range paths {
|
||||||
|
if excludedByPath(filePath, normalized) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
benchmarkExclusionMatches = count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBenchmarkExclusionIndexPreservesLiteralMatching(t *testing.T) {
|
||||||
|
for _, exclusions := range [][]string{{"."}, {"/"}, {"a//b", "../x", "", "a/b"}, {"a", "a/b"}} {
|
||||||
|
index := benchmarkExclusionIndex(exclusions)
|
||||||
|
for _, filePath := range []string{"a", "a/b", "a/b/c", "ab/c", "/", "//file", "../x/file", "other"} {
|
||||||
|
if got, want := benchmarkExcluded(filePath, index), excludedByPath(filePath, normalizedExclusions(exclusions)); got != want {
|
||||||
|
t.Errorf("path %q exclusions %s: indexed %t, scan %t", filePath, strings.Join(exclusions, ","), got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ func TestParseMarkdownHeadingAnchors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarkdownCacheSharesParsedDocumentsByBlobObjectID(t *testing.T) {
|
func TestMarkdownDocumentsParseIdenticalAndDistinctBlobs(t *testing.T) {
|
||||||
repository := newTreeRepository(t)
|
repository := newTreeRepository(t)
|
||||||
shared := []byte("# Guide\n[other](other.md \"title\")\n")
|
shared := []byte("# Guide\n[other](other.md \"title\")\n")
|
||||||
writeTreeFile(t, repository, "one/guide.md", shared)
|
writeTreeFile(t, repository, "one/guide.md", shared)
|
||||||
@@ -111,20 +111,17 @@ func TestMarkdownCacheSharesParsedDocumentsByBlobObjectID(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if first != second {
|
|
||||||
t.Fatal("identical Markdown blobs returned different parsed documents")
|
|
||||||
}
|
|
||||||
other, err := cache.document(context.Background(), "one/other.md")
|
other, err := cache.document(context.Background(), "one/other.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if other == first {
|
|
||||||
t.Fatal("different Markdown blobs shared a parsed document")
|
|
||||||
}
|
|
||||||
if _, ok := other.anchors["other"]; !ok {
|
if _, ok := other.anchors["other"]; !ok {
|
||||||
t.Fatalf("other document anchors = %#v", other.anchors)
|
t.Fatalf("other document anchors = %#v", other.anchors)
|
||||||
}
|
}
|
||||||
if got := first.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
|
if got := first.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
|
||||||
t.Fatalf("shared Markdown link = %#v, want content-relative destination, title, and location", got)
|
t.Fatalf("shared Markdown link = %#v, want content-relative destination, title, and location", got)
|
||||||
}
|
}
|
||||||
|
if got := second.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
|
||||||
|
t.Fatalf("second Markdown link = %#v, want content-relative destination, title, and location", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run executes shared validation for the captured candidate.
|
// Run executes shared validation for the captured candidate.
|
||||||
func Run(ctx context.Context, request Request) (result error) {
|
func Run(ctx context.Context, request Request) error {
|
||||||
if request.Settings.FileSize != nil && request.Settings.FileSize.LimitBytes != nil && *request.Settings.FileSize.LimitBytes < 0 {
|
if request.Settings.FileSize != nil && request.Settings.FileSize.LimitBytes != nil && *request.Settings.FileSize.LimitBytes < 0 {
|
||||||
return &Failure{
|
return &Failure{
|
||||||
Check: FileSize,
|
Check: FileSize,
|
||||||
@@ -89,6 +89,13 @@ func Run(ctx context.Context, request Request) (result error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := runGoChecks(ctx, request); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runTreeChecks(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGoChecks(ctx context.Context, request Request) error {
|
||||||
goDirectory := request.ModuleDir
|
goDirectory := request.ModuleDir
|
||||||
if goDirectory == "" {
|
if goDirectory == "" {
|
||||||
goDirectory = request.RepositoryDir
|
goDirectory = request.RepositoryDir
|
||||||
@@ -134,6 +141,10 @@ func Run(ctx context.Context, request Request) (result error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTreeChecks(ctx context.Context, request Request) (result error) {
|
||||||
needsSharedSelection := len(request.Settings.SharedFiles) != 0
|
needsSharedSelection := len(request.Settings.SharedFiles) != 0
|
||||||
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection || len(request.Settings.ForbiddenFiles) != 0 {
|
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection || len(request.Settings.ForbiddenFiles) != 0 {
|
||||||
tree, err := request.ReadCandidateTree(ctx)
|
tree, err := request.ReadCandidateTree(ctx)
|
||||||
|
|||||||
@@ -3,15 +3,99 @@ package releasekit_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
releasekit "go.maximumdirect.net/releasekit"
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestPrepareTaggedCheckoutRedactsRemoteCredentials(t *testing.T) {
|
||||||
|
t.Setenv("NO_PROXY", "*")
|
||||||
|
t.Setenv("no_proxy", "*")
|
||||||
|
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||||
|
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
var authenticated atomic.Bool
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, password, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="fixture"`)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user != "review-user" || password != "review-password" {
|
||||||
|
t.Error("fetch did not preserve the configured credentials")
|
||||||
|
}
|
||||||
|
authenticated.Store(true)
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
remote := strings.Replace(server.URL, "http://", "http://review-user:review-password@", 1) + "/repo.git"
|
||||||
|
err := releasekit.PrepareTaggedCheckout(t.Context(), releasekit.TaggedCheckoutRequest{
|
||||||
|
RepositoryDir: repo.work, Tag: "v1.2.3", Remote: remote, ExpectedCommit: repo.commit,
|
||||||
|
})
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || !authenticated.Load() || !strings.Contains(err.Error(), "v1.2.3") || !strings.Contains(err.Error(), "repo.git") {
|
||||||
|
t.Fatalf("preparation failure = %v, authenticated = %t", err, authenticated.Load())
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "review-user") || strings.Contains(err.Error(), "review-password") {
|
||||||
|
t.Fatalf("preparation diagnostic exposes credentials: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareBuildDependenciesCancellationReleasesInheritedOutput(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
ready := filepath.Join(t.TempDir(), "descendant")
|
||||||
|
gitPath := filepath.Join(t.TempDir(), "git")
|
||||||
|
if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nsleep 60 &\nprintf '%s' \"$!\" > \"$RELEASEKIT_PREPARE_DESCENDANT\"\nwait\n"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", filepath.Dir(gitPath)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("RELEASEKIT_PREPARE_DESCENDANT", ready)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
result <- releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{RepositoryDir: directory})
|
||||||
|
}()
|
||||||
|
deadline := time.After(10 * time.Second)
|
||||||
|
var pid int
|
||||||
|
for pid == 0 {
|
||||||
|
if data, err := os.ReadFile(ready); err == nil {
|
||||||
|
pid, _ = strconv.Atoi(string(data))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("preparation descendant did not start")
|
||||||
|
default:
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if descendant, err := os.FindProcess(pid); err == nil {
|
||||||
|
_ = descendant.Kill()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("preparation error = %v, want cancellation", err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("preparation did not return after cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) {
|
func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) {
|
||||||
repository := newTaggedDeliveryConsumer(t)
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
tagCommit := publishPreparationTag(t, repository)
|
tagCommit := publishPreparationTag(t, repository)
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ type PublicationResult struct {
|
|||||||
Tag string // complete selected Git tag.
|
Tag string // complete selected Git tag.
|
||||||
Version string // selected stable version
|
Version string // selected stable version
|
||||||
CandidateCommit string // captured main commit
|
CandidateCommit string // captured main commit
|
||||||
Endpoint string // captured effective push URL
|
Endpoint string // captured effective push URL, which may contain credentials
|
||||||
Phase PublicationPhase // last operation phase reached
|
Phase PublicationPhase // last operation phase reached
|
||||||
TagObject string // local annotated tag object ID, when created
|
TagObject string // local annotated tag object ID, when established; a local ref may remain even when empty
|
||||||
State PublicationState // publication outcome for this invocation
|
State PublicationState // publication outcome for this invocation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
153
publish_test.go
153
publish_test.go
@@ -14,10 +14,54 @@ import (
|
|||||||
|
|
||||||
releasekit "go.maximumdirect.net/releasekit"
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
"go.maximumdirect.net/releasekit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
)
|
)
|
||||||
|
|
||||||
const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes stay intact.\n"
|
const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes stay intact.\n"
|
||||||
|
|
||||||
|
func TestPublishPreservesHookDirectoryEnvironment(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("directory-derived PWD is a POSIX behavior")
|
||||||
|
}
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
marker := filepath.Join(t.TempDir(), "hook-ran")
|
||||||
|
t.Setenv("RELEASEKIT_HOOK_MARKER", marker)
|
||||||
|
helperDir := t.TempDir()
|
||||||
|
writeFile(t, helperDir, "main.go", `package main
|
||||||
|
import "os"
|
||||||
|
func main() {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil || os.Getenv("PWD") != cwd {
|
||||||
|
os.Stderr.WriteString("pre-push PWD does not identify the selected repository\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(os.Getenv("RELEASEKIT_HOOK_MARKER"), []byte("checked"), 0600); err != nil {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
hooks := filepath.Join(repo.work, ".git", "hooks")
|
||||||
|
if err := os.MkdirAll(hooks, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gitRun(t, repo.work, "config", "core.hooksPath", hooks)
|
||||||
|
tool, err := gotool.New(t.Context(), repo.work)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tool.Run(t.Context(), repo.work, gotool.Static, tool.HostTarget(), nil,
|
||||||
|
"build", "-o", filepath.Join(hooks, "pre-push"), filepath.Join(helperDir, "main.go")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := releasekit.Publish(t.Context(), releasekit.ReleaseRequest{RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3"})
|
||||||
|
if err != nil || result.State != releasekit.PublicationPublished {
|
||||||
|
t.Fatalf("publication with directory-aware hook = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(marker); err != nil || string(data) != "checked" {
|
||||||
|
t.Fatalf("pre-push hook observation = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckRejectsInvalidDerivedTagBeforeValidation(t *testing.T) {
|
func TestCheckRejectsInvalidDerivedTagBeforeValidation(t *testing.T) {
|
||||||
for _, module := range []string{"module space", "module.lock"} {
|
for _, module := range []string{"module space", "module.lock"} {
|
||||||
t.Run(module, func(t *testing.T) {
|
t.Run(module, func(t *testing.T) {
|
||||||
@@ -92,8 +136,8 @@ func TestCandidateOperationsDiscoverGoOnlyWhenLaterWorkNeedsIt(t *testing.T) {
|
|||||||
|
|
||||||
func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
||||||
repo := newPublishFixture(t)
|
repo := newPublishFixture(t)
|
||||||
wrapper := installGitWrapper(t, repo, "pass")
|
gitRun(t, repo.work, "config", "push.recurseSubmodules", "only")
|
||||||
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
gitRun(t, repo.work, "replace", repo.unrelatedCommit, repo.commit)
|
||||||
suiteRuns := filepath.Join(t.TempDir(), "suite-runs")
|
suiteRuns := filepath.Join(t.TempDir(), "suite-runs")
|
||||||
t.Setenv("RELEASEKIT_FIXTURE_SUITE_RUNS", suiteRuns)
|
t.Setenv("RELEASEKIT_FIXTURE_SUITE_RUNS", suiteRuns)
|
||||||
refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
|
refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
|
||||||
@@ -124,9 +168,6 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
|||||||
if runs, err := os.ReadFile(suiteRuns); err != nil || string(runs) != "ran\n" {
|
if runs, err := os.ReadFile(suiteRuns); err != nil || string(runs) != "ran\n" {
|
||||||
t.Fatalf("standard suite runs = %q, error = %v; want one run", runs, err)
|
t.Fatalf("standard suite runs = %q, error = %v; want one run", runs, err)
|
||||||
}
|
}
|
||||||
if queries := countLines(t, wrapper.queryLog); queries != 3 {
|
|
||||||
t.Fatalf("authoritative tag query count = %d, want 3 guards and no success read-back", queries)
|
|
||||||
}
|
|
||||||
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
|
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
|
||||||
t.Fatalf("publication result = %#v", result)
|
t.Fatalf("publication result = %#v", result)
|
||||||
}
|
}
|
||||||
@@ -166,6 +207,36 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishRejectsReplacementDerivedCheckout(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
writeFile(t, repo.work, "broken.go", "package fixture\nfunc broken(\n")
|
||||||
|
gitRun(t, repo.work, "add", "broken.go")
|
||||||
|
gitRun(t, repo.work, "commit", "-m", "Original source does not compile")
|
||||||
|
original := gitText(t, repo.work, "rev-parse", "HEAD")
|
||||||
|
gitRun(t, repo.work, "-c", "remote.origin.mirror=false", "push", "origin", "HEAD:refs/heads/main")
|
||||||
|
writeFile(t, repo.work, "broken.go", "package fixture\nfunc valid() {}\n")
|
||||||
|
writeFile(t, repo.work, "docs/releases/v1.2.3.md", "# Sample App v1.2.3\n\nReplacement note.\n")
|
||||||
|
gitRun(t, repo.work, "add", "broken.go", "docs/releases/v1.2.3.md")
|
||||||
|
gitRun(t, repo.work, "commit", "-m", "Replacement source")
|
||||||
|
replacement := gitText(t, repo.work, "rev-parse", "HEAD")
|
||||||
|
gitRun(t, repo.work, "reset", "--hard", original)
|
||||||
|
gitRun(t, repo.work, "replace", original, replacement)
|
||||||
|
gitRun(t, repo.work, "reset", "--hard", original)
|
||||||
|
contents, err := os.ReadFile(filepath.Join(repo.work, "broken.go"))
|
||||||
|
if err != nil || !strings.Contains(string(contents), "func valid") {
|
||||||
|
t.Fatalf("replacement checkout = %q, error = %v", contents, err)
|
||||||
|
}
|
||||||
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "clean") || result.State != releasekit.PublicationNotAttempted {
|
||||||
|
t.Fatalf("publication = %#v, error = %v; want dirty source rejection", result, err)
|
||||||
|
}
|
||||||
|
if err := exec.Command("git", "-C", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").Run(); err == nil {
|
||||||
|
t.Fatal("replacement-derived source was published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishUsesSeparateFullTagsForModulesAtOneCommit(t *testing.T) {
|
func TestPublishUsesSeparateFullTagsForModulesAtOneCommit(t *testing.T) {
|
||||||
repo := newPublishFixture(t)
|
repo := newPublishFixture(t)
|
||||||
firstDir, secondDir := "integrations/first", "integrations/second"
|
firstDir, secondDir := "integrations/first", "integrations/second"
|
||||||
@@ -305,6 +376,78 @@ func TestPublishRejectsUnexpectedPushURLBeforeValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishRedactsCredentialsInEndpointMismatch(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
resolved := "https://actual-user:actual-secret@example.invalid/project.git"
|
||||||
|
expected := "https://expected-token@example.invalid/project.git"
|
||||||
|
gitRun(t, repo.work, "remote", "set-url", "--push", "origin", resolved)
|
||||||
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", ExpectedPushURL: expected,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, releasekit.ErrUnexpectedPushURL) || result.Endpoint != resolved || result.State != releasekit.PublicationNotAttempted {
|
||||||
|
t.Fatalf("mismatch result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"actual-user", "actual-secret", "expected-token"} {
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Fatalf("diagnostic exposes %q: %v", secret, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "example.invalid/project.git") {
|
||||||
|
t.Fatalf("diagnostic lacks endpoint context: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishRetainsEvidenceWhenCreatedTagInspectionFails(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, failingCommand string
|
||||||
|
wantObject bool
|
||||||
|
}{
|
||||||
|
{"type inspection", "cat-file -t refs/tags/v1.2.3", false},
|
||||||
|
{"peeled commit inspection", "rev-parse --verify refs/tags/v1.2.3^{}", true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
realGit := mustLookPath(t, "git")
|
||||||
|
bin := t.TempDir()
|
||||||
|
pushMarker := filepath.Join(t.TempDir(), "push")
|
||||||
|
script := fmt.Sprintf(`#!/bin/sh
|
||||||
|
case " $* " in
|
||||||
|
*" %s "*) echo "inspection failed" >&2; exit 77 ;;
|
||||||
|
*" push "*) : > %s ;;
|
||||||
|
esac
|
||||||
|
exec %s "$@"
|
||||||
|
`, test.failingCommand, shellQuote(pushMarker), shellQuote(realGit))
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
|
||||||
|
})
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || exit.ExitCode() != 77 || result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationTagCreation {
|
||||||
|
t.Fatalf("publication = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refs/tags/v1.2.3") || !strings.Contains(err.Error(), "may remain") {
|
||||||
|
t.Fatalf("missing local ref evidence: %v", err)
|
||||||
|
}
|
||||||
|
actual, readErr := exec.Command(realGit, "-C", repo.work, "rev-parse", "--verify", "refs/tags/v1.2.3").Output()
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
if test.wantObject && result.TagObject != strings.TrimSpace(string(actual)) || !test.wantObject && result.TagObject != "" {
|
||||||
|
t.Fatalf("tag object = %q, local ref = %q", result.TagObject, actual)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(pushMarker); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("unexpected push marker: %v", err)
|
||||||
|
}
|
||||||
|
if err := exec.Command(realGit, "-C", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").Run(); err == nil {
|
||||||
|
t.Fatal("remote tag was published")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishComparesExpectedURLWithResolvedInsteadOfDestination(t *testing.T) {
|
func TestPublishComparesExpectedURLWithResolvedInsteadOfDestination(t *testing.T) {
|
||||||
repo := newPublishFixture(t)
|
repo := newPublishFixture(t)
|
||||||
alias := "releasekit-expected-push:"
|
alias := "releasekit-expected-push:"
|
||||||
|
|||||||
Reference in New Issue
Block a user