39 Commits

Author SHA1 Message Date
6fab30337a Prepare v0.7.0 notes and retire completed roadmaps 2026-11-02 13:54:16 +00:00
e736166a39 Hide credentials in checkout preparation errors 2026-11-02 11:45:42 +00:00
a2d75c462c Preserve child working directory in environment overlays 2026-11-02 11:36:40 +00:00
31f9e48219 Redact quoted credentials in Git diagnostics 2026-11-02 11:32:11 +00:00
473670185c Record release workflow integration gaps 2026-11-02 04:06:35 +00:00
c8fa165dc9 Record the implementation review finding 2026-11-02 03:53:36 +00:00
2f95753d9c Verify the audit remediation changes 2026-11-02 03:41:01 +00:00
9dc87ae996 Record deferred resource assessments 2026-11-02 03:36:53 +00:00
d9c97fd915 Remove unused helpers and incidental assertions 2026-11-02 03:34:59 +00:00
1d40fb7ac0 Extract check and build command workflows 2026-11-02 03:34:06 +00:00
03ee83e8a5 Separate validation commands from tree checks 2026-11-02 03:32:46 +00:00
9cb48a70b7 Bound retained Go command diagnostics 2026-11-02 03:31:59 +00:00
2b23574ece Bound remote asset content verification 2026-11-02 03:29:59 +00:00
76676ee5a6 Limit release asset redirects 2026-11-02 03:28:24 +00:00
23b3a3d340 Preserve delivery outcomes when cleanup fails 2026-11-02 03:27:13 +00:00
ef47356541 Report temporary metadata cleanup failures 2026-11-02 03:24:57 +00:00
0666b68ff6 Share artifact declaration validation 2026-11-02 03:23:15 +00:00
6f2e62bea3 Report only fully hashed build artifacts 2026-11-02 03:22:07 +00:00
aa57fee4de Preserve evidence when local tag inspection fails 2026-11-02 03:20:13 +00:00
92c95ef031 Hide credentials in Git endpoint diagnostics 2026-11-02 03:19:16 +00:00
595745c7b1 Close piped processes on cancellation 2026-11-02 03:16:36 +00:00
9c02ed769b Bound subprocess output teardown 2026-11-02 03:14:59 +00:00
94aedacf05 Verify releases use original Git objects 2026-11-02 03:14:01 +00:00
7e6e0cc7d1 Observe original Git objects consistently 2026-11-02 03:12:05 +00:00
aecab00ff6 Ensure tag pushes ignore submodule push settings 2026-11-02 03:08:58 +00:00
a1dc705be7 Plan the audit remediation sprint 2026-11-02 03:00:05 +00:00
b5b3f09599 Record accepted audit remediation decisions 2026-11-02 02:23:58 +00:00
6f7e43b097 Consolidate audit findings and remediation priorities 2026-11-02 02:16:40 +00:00
91f25c452c Record dead code audit findings 2026-11-02 01:47:21 +00:00
083bc83331 Record shared behavior audit findings 2026-11-02 01:45:54 +00:00
5e6db4de76 Record interface contract audit finding 2026-11-02 01:44:55 +00:00
b345bc0ec4 Record test coverage audit findings 2026-11-02 01:43:27 +00:00
3d7a691338 Record error handling audit finding 2026-11-02 01:41:31 +00:00
3754242969 Record resource management audit findings 2026-11-02 01:40:06 +00:00
9545bb0bfc Record complexity audit findings 2026-11-02 01:39:06 +00:00
620c832477 Record efficiency audit findings 2026-11-02 01:38:01 +00:00
812c24de9e Record security audit findings 2026-11-02 01:27:24 +00:00
1b39e71e81 Record concurrency audit findings 2026-11-02 01:16:22 +00:00
d884ad731d Record functional correctness audit findings 2026-11-02 01:07:48 +00:00
57 changed files with 2453 additions and 481 deletions

View File

@@ -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})
} }

View File

@@ -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 (

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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":

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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` |

View File

@@ -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.

View File

@@ -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
View 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)

View 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.

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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,

View 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)
}
})
}
}

View File

@@ -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 {

View File

@@ -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)
} }

View 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)
}
}
})
}
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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,
}) })

View File

@@ -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
View 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 }

View 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)
}
}

View File

@@ -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 {

View File

@@ -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
} }

View File

@@ -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,

View File

@@ -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)
}
})
}
}

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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" {

View File

@@ -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
} }

View File

@@ -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")

View File

@@ -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))
} }
} }
}) })
} }
}
} }

View File

@@ -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

View File

@@ -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')

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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()

View 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)
}
}
}
}

View File

@@ -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)
}
} }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
} }

View File

@@ -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:"