79 Commits

Author SHA1 Message Date
23203f235c Clarify credential redaction and tool registration in release notes 2026-11-03 00:41:40 +00:00
37d6209aec Prepare v0.8.0 notes and retire completed roadmaps 2026-11-03 00:29:51 +00:00
ee72566263 Verify vendored recovery after launcher cleanup 2026-11-03 00:23:06 +00:00
e0019abe31 Validate byte-preserving record fields as base64 strings 2026-11-03 00:18:47 +00:00
2a7bed87db Classify recovery setup failures by their actual cause 2026-11-03 00:15:36 +00:00
dfaa652098 Keep recovery handoffs available after publication cancellation 2026-11-03 00:12:30 +00:00
cd08a2892a Run API-only recovery independently of consumer vendoring 2026-11-03 00:05:32 +00:00
16d1c072e3 Record remaining recovery findings and recommended fixes 2026-11-03 00:00:42 +00:00
363f7cd513 Retain discovered publication records when enumeration fails 2026-11-02 22:41:18 +00:00
cd274512e7 Reject trailing record JSON without decoding a second value 2026-11-02 22:37:12 +00:00
3b8bacf114 Filter publication record names before sorting directory results 2026-11-02 22:36:35 +00:00
781341a0ba Preserve exact evidence bytes in private publication records 2026-11-02 22:35:53 +00:00
109596bc3b Use the source-built CLI in maintainer recovery instructions 2026-11-02 22:34:19 +00:00
6fc140d8dc Verify record storage preserves whitespace in Git paths 2026-11-02 22:33:29 +00:00
993866c1d7 Keep publication recovery usable after cold Go tool runs 2026-11-02 22:32:58 +00:00
19a14bfeb6 Report JSON output failures from recovery commands 2026-11-02 22:23:15 +00:00
648c87bb8a Redact credentials throughout publication recovery displays 2026-11-02 22:22:31 +00:00
04db23705a Honor cancellation throughout publication recovery Git operations 2026-11-02 22:20:53 +00:00
9a7b9b9301 Allow explicit publication stores in Git metadata 2026-11-02 22:18:51 +00:00
d2b1329095 Check the exact publication record filename before writing 2026-11-02 22:17:59 +00:00
0d19a5546f Preserve candidate failures when initializing publication records 2026-11-02 22:17:29 +00:00
da1839c6d2 Record approved recovery remediation decisions 2026-11-02 22:13:04 +00:00
957e75524c Record publication recovery algorithm findings 2026-11-02 21:13:44 +00:00
0ef135bfda Record publication recovery integration findings 2026-11-02 20:53:01 +00:00
3280f734ef Record publication recovery review findings 2026-11-02 20:24:44 +00:00
599ad81bec Finish publication recovery workflow 2026-11-02 19:37:29 +00:00
6915a86132 Add publication recovery handoff to CLI 2026-11-02 19:22:23 +00:00
4afdd9b826 Add CLI publication recovery commands 2026-11-02 19:17:42 +00:00
fb5947ea3a Add local publication attempt discovery 2026-11-02 19:09:26 +00:00
2a04938a96 Record publication evidence before tag pushes 2026-11-02 19:08:07 +00:00
b11c6d1788 Expose publication recovery facade 2026-11-02 19:03:24 +00:00
1ddeb2c6dc Add private publication attempt records 2026-11-02 19:00:51 +00:00
4dff8086ab Extract publication recovery observation 2026-11-02 18:58:48 +00:00
447b42a1cd Plan publication reconciliation implementation 2026-11-02 18:24:44 +00:00
58656dea45 Clarify recovery transport settings and partial discovery 2026-11-02 17:57:05 +00:00
58c20fa863 Add recovery ergonomics to reconciliation roadmap 2026-11-02 17:43:27 +00:00
f73f0c1b6a Clarify publication recording and recovery selection 2026-11-02 17:40:38 +00:00
bff4ed8a8b Resolve publication reconciliation roadmap decisions 2026-11-02 17:17:23 +00:00
e0bf5ca57a Plan read-only publication reconciliation 2026-11-02 16:50:16 +00:00
691926d839 Run consumer release commands through Go tools 2026-11-02 14:46:42 +00:00
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
85 changed files with 6786 additions and 668 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

@@ -46,6 +46,11 @@ type ReleaseRequest struct {
// this string exactly before validation or tag creation. Check ignores this // this string exactly before validation or tag creation. Check ignores this
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL. // publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
ExpectedPushURL string ExpectedPushURL string
// PublicationRecordDir optionally overrides Publish's private attempt-record
// directory. Relative paths are resolved from the source repository root;
// Check ignores this publication-only setting. Recording failure is returned
// separately on PublicationResult and does not change publication status.
PublicationRecordDir string
// Validation selects optional checks run against the captured candidate. // Validation selects optional checks run against the captured candidate.
Validation ValidationSpec Validation ValidationSpec
// AdditionalChecks optionally runs product checks after shared validation. // AdditionalChecks optionally runs product checks after shared validation.

View File

@@ -17,17 +17,19 @@ import (
"go.maximumdirect.net/releasekit/internal/versioncontract" "go.maximumdirect.net/releasekit/internal/versioncontract"
) )
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG" const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--record-dir DIR] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG\n releasekit reconcile --record FILE [--json]\n releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]"
type Command string type Command string
const ( const (
VersionCommand Command = "version" VersionCommand Command = "version"
CheckAction Command = "check" CheckAction Command = "check"
PublishAction Command = "publish" PublishAction Command = "publish"
BuildAction Command = "build" BuildAction Command = "build"
DeliveryAction Command = "deliver" DeliveryAction Command = "deliver"
CIDeliveryAction Command = "ci-deliver" CIDeliveryAction Command = "ci-deliver"
ReconcileAction Command = "reconcile"
PublicationAttemptsAction Command = "publication-attempts"
) )
// Invocation contains parsed command settings owned by the CLI adapter. // Invocation contains parsed command settings owned by the CLI adapter.
@@ -43,6 +45,9 @@ type Invocation struct {
ExpectedModule string ExpectedModule string
ExpectedTag string ExpectedTag string
Prepared bool Prepared bool
RecordPath string
RecordDir string
JSON bool
} }
// Parse accepts only commands that have been implemented by the executable. // Parse accepts only commands that have been implemented by the executable.
@@ -51,6 +56,30 @@ func Parse(args []string) (Invocation, error) {
return Invocation{}, errors.New("a command is required") return Invocation{}, errors.New("a command is required")
} }
switch args[0] { switch args[0] {
case string(ReconcileAction), string(PublicationAttemptsAction):
action := Command(args[0])
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
flags.SetOutput(io.Discard)
record := ""
recordDir := ""
repository := "."
if action == ReconcileAction {
flags.StringVar(&record, "record", "", "attempt record file")
} else {
flags.StringVar(&repository, "repo", ".", "repository directory")
flags.StringVar(&recordDir, "record-dir", "", "attempt-record directory")
}
structured := flags.Bool("json", false, "write structured JSON")
if err := flags.Parse(args[1:]); err != nil {
return Invocation{}, err
}
if flags.NArg() != 0 {
return Invocation{}, fmt.Errorf("%s takes no positional arguments", action)
}
if action == ReconcileAction && record == "" {
return Invocation{}, errors.New("reconcile requires --record FILE")
}
return Invocation{Command: action, Repository: repository, RecordPath: record, RecordDir: recordDir, JSON: *structured}, nil
case string(VersionCommand): case string(VersionCommand):
if len(args) != 1 { if len(args) != 1 {
return Invocation{}, errors.New("version takes no arguments") return Invocation{}, errors.New("version takes no arguments")
@@ -75,6 +104,10 @@ func Parse(args []string) (Invocation, error) {
if action == PublishAction || action == CIDeliveryAction { if action == PublishAction || action == CIDeliveryAction {
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping") flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
} }
recordDir := ""
if action == PublishAction {
flags.StringVar(&recordDir, "record-dir", "", "publication attempt-record directory")
}
expectedCommit := "" expectedCommit := ""
prepared := false prepared := false
if action == CIDeliveryAction { if action == CIDeliveryAction {
@@ -112,7 +145,7 @@ func Parse(args []string) (Invocation, error) {
if remote != nil { if remote != nil {
parsedRemote = *remote parsedRemote = *remote
} }
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared}, nil return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared, RecordDir: recordDir}, nil
default: default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0]) return Invocation{}, fmt.Errorf("unknown command %q", args[0])
} }

View File

@@ -19,6 +19,33 @@ func TestParse(t *testing.T) {
if invocation.Command != VersionCommand { if invocation.Command != VersionCommand {
t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand) t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand)
} }
reconcile, err := Parse([]string{"reconcile", "--record", "records/release record's.json", "--json"})
if err != nil {
t.Fatal(err)
}
if reconcile.Command != ReconcileAction || reconcile.RecordPath != "records/release record's.json" || !reconcile.JSON {
t.Fatalf("Parse(reconcile) = %#v", reconcile)
}
attempts, err := Parse([]string{"publication-attempts", "--repo", "repository with spaces", "--record-dir", "records with spaces", "--json"})
if err != nil {
t.Fatal(err)
}
if attempts.Command != PublicationAttemptsAction || attempts.Repository != "repository with spaces" || attempts.RecordDir != "records with spaces" || !attempts.JSON {
t.Fatalf("Parse(publication-attempts) = %#v", attempts)
}
for _, args := range [][]string{
{"reconcile"},
{"reconcile", "record.json"},
{"reconcile", "--record", "record.json", "--repo", "."},
{"reconcile", "--record", "record.json", "--record-dir", "records"},
{"reconcile", "--record", "record.json", "VERSION"},
{"publication-attempts", "--record", "record.json"},
{"publication-attempts", "VERSION"},
} {
if _, err := Parse(args); err == nil {
t.Errorf("Parse(%q) succeeded, want a usage error", args)
}
}
check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"}) check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -26,13 +53,16 @@ func TestParse(t *testing.T) {
if check.Command != CheckAction || check.Repository != "repository with spaces" || check.ConfigPath != "profiles/release config.json" || check.Version != "v1.2.3" { if check.Command != CheckAction || check.Repository != "repository with spaces" || check.ConfigPath != "profiles/release config.json" || check.Version != "v1.2.3" {
t.Fatalf("Parse(check) = %#v", check) t.Fatalf("Parse(check) = %#v", check)
} }
publish, err := Parse([]string{"publish", "--repo", "candidate", "--config", "releasekit.json", "v1.2.4"}) publish, err := Parse([]string{"publish", "--repo", "candidate", "--config", "releasekit.json", "--record-dir", "attempt records", "v1.2.4"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" { if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.RecordDir != "attempt records" || publish.Version != "v1.2.4" {
t.Fatalf("Parse(publish) = %#v", publish) t.Fatalf("Parse(publish) = %#v", publish)
} }
if _, err := Parse([]string{"check", "--record-dir", "attempt records", "v1.2.4"}); err == nil {
t.Fatal("Parse(check) accepted the publication-only record directory")
}
publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"}) publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"})
if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" { if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" {
t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err) t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err)

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] {
return fmt.Errorf("build receipt repeats target %s/%s", target.OS, target.Arch)
}
seenTargets[target] = true
targets = append(targets, artifact.Target{OS: target.OS, Arch: target.Arch})
} }
declarations := artifact.ExpectedBuild(receipt.BinaryName, receipt.Version, targets) if err := artifact.ValidateDeclarations(receipt.BinaryName, receipt.Version, parseStoredTargets(receipt.Targets), declarations); err != nil {
wantByTarget := make(map[ReceiptTarget]string, len(declarations)) return fmt.Errorf("build receipt: %w", err)
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

@@ -10,12 +10,14 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"syscall" "syscall"
"time"
"go.maximumdirect.net/releasekit" "go.maximumdirect.net/releasekit"
"go.maximumdirect.net/releasekit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
"go.maximumdirect.net/releasekit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
"go.maximumdirect.net/releasekit/internal/git" "go.maximumdirect.net/releasekit/internal/git"
"go.maximumdirect.net/releasekit/internal/gotool"
"go.maximumdirect.net/releasekit/internal/releaseidentity" "go.maximumdirect.net/releasekit/internal/releaseidentity"
) )
@@ -32,6 +34,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage) fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
return 2 return 2
} }
if invocation.Command == cli.ReconcileAction || invocation.Command == cli.PublicationAttemptsAction {
return runRecovery(ctx, invocation, stdout, stderr)
}
if invocation.Command != cli.VersionCommand { if invocation.Command != cli.VersionCommand {
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository) invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
if err != nil { if err != nil {
@@ -45,102 +50,9 @@ 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:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) return runCheckOrPublish(ctx, invocation, stdout, stderr)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
request := candidateRequest(invocation, settings, stderr)
if invocation.Command == cli.PublishAction {
if err := checkExpectedModule(ctx, invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil {
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
return 2
}
request.ExpectedPushURL = settings.ExpectedPushURL
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
result, err := releasekit.Publish(ctx, request)
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))
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
if result.State == releasekit.PublicationUnknown {
return 3
}
return 1
}
fmt.Fprintf(stdout, "published %s as %s at %s (tag object %s)\n", result.Version, result.Tag, result.CandidateCommit, result.TagObject)
return 0
}
candidate, err := releasekit.Check(ctx, request)
if err != nil {
fmt.Fprintf(stderr, "check failed module=%s module-dir=%s version=%s tag=%s commit=%s: %s\n", candidate.ModulePath, candidate.ModuleDir, candidate.Version, candidate.Tag, candidate.Commit, candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
return 1
}
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
case cli.BuildAction: case cli.BuildAction:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) return runBuild(ctx, invocation, stdout, stderr)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
if settings.Build == nil {
fmt.Fprintln(stderr, "build requires build settings in the profile")
return 2
}
result, err := releasekit.Build(ctx, releasekit.BuildRequest{
RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build),
OutputDir: invocation.OutputDir,
Output: stderr,
})
if result.OutputDir != "" && len(result.Targets) != 0 {
if receiptErr := cli.RemoveBuildReceipt(result.OutputDir); receiptErr != nil {
fmt.Fprintf(stderr, "%v\n", receiptErr)
return 1
}
}
if err != nil {
fmt.Fprintf(stderr, "build failed module=%s module-dir=%s version=%s tag=%s commit=%s: %v\n", result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, err)
for _, artifact := range result.Artifacts {
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
}
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
return 1
}
if len(result.Targets) == 0 {
fmt.Fprintf(stdout, "no build targets selected for module %s version %s tag %s at %s\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit)
return 0
}
receipt := receiptFromBuild(result)
receiptPath, err := cli.WriteBuildReceipt(result.OutputDir, receipt)
if err != nil {
fmt.Fprintf(stderr, "write build receipt: %v\n", err)
return 1
}
stored, err := cli.LoadBuildReceipt(receiptPath)
if err != nil {
fmt.Fprintf(stderr, "verify build receipt: %v\n", err)
return 1
}
if !stored.MatchesBuild(result.RepositoryDir, result.CandidateCommit, result.Version, *settings.Build) {
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
return 1
}
fmt.Fprintf(stdout, "built %d artifacts for module %s version %s tag %s at %s with %s\n", len(result.Artifacts), result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.Compiler)
for _, artifact := range result.Artifacts {
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
}
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
return 0
case cli.DeliveryAction: case cli.DeliveryAction:
return runDelivery(ctx, invocation, stdout, stderr) return runDelivery(ctx, invocation, stdout, stderr)
case cli.CIDeliveryAction: case cli.CIDeliveryAction:
@@ -149,6 +61,196 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return 2 return 2
} }
func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
request := candidateRequest(invocation, settings, stderr)
if invocation.Command == cli.PublishAction {
if err := checkExpectedModule(ctx, invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil {
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
return 2
}
request.ExpectedPushURL = settings.ExpectedPushURL
request.PublicationRecordDir = invocation.RecordDir
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
result, err := releasekit.Publish(ctx, request)
reportPublicationRecord(ctx, result, err, stderr)
if err != nil {
fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.TagObject, git.DisplayURL(result.Endpoint), candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
if result.State == releasekit.PublicationUnknown {
return 3
}
return 1
}
fmt.Fprintf(stdout, "published %s as %s at %s (tag object %s)\n", result.Version, result.Tag, result.CandidateCommit, result.TagObject)
return 0
}
candidate, err := releasekit.Check(ctx, request)
if err != nil {
fmt.Fprintf(stderr, "check failed module=%s module-dir=%s version=%s tag=%s commit=%s: %s\n", candidate.ModulePath, candidate.ModuleDir, candidate.Version, candidate.Tag, candidate.Commit, candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
return 1
}
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
}
func reportPublicationRecord(ctx context.Context, result releasekit.PublicationResult, publicationErr error, stderr io.Writer) {
if result.RecordError != nil {
fmt.Fprintf(stderr, "publication record warning: %s\n", git.DisplayError(result.RecordError))
}
if result.RecordPath == "" {
return
}
fmt.Fprintf(stderr, "publication record saved: %s\n", git.DisplayDiagnostic(result.RecordPath))
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
return
}
// Handoff preparation follows the failed publication and has its own bounded
// lifetime, including when publication's context was canceled.
handoffContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
command, err := selectedPublicationRecoveryCommand(handoffContext, result)
if err != nil {
fmt.Fprintf(stderr, "recovery tool preparation warning: %s\n", git.DisplayError(err))
command, err = publicationRecoveryCommand(result.RecordPath)
if err != nil {
fmt.Fprintf(stderr, "recovery command unavailable: %s\n", git.DisplayError(err))
fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.")
return
}
}
fmt.Fprintf(stderr, "recovery command: %s\n", git.DisplayDiagnostic(command))
fmt.Fprintln(stderr, "If this entry point is unavailable, use your selected supported ReleaseKit CLI with the same record.")
}
func selectedPublicationRecoveryCommand(ctx context.Context, result releasekit.PublicationResult) (string, error) {
if result.Evidence.ModuleDir != "" {
common, err := git.CommonDirectory(ctx, result.RepositoryDir)
if err != nil {
return "", err
}
args, err := gotool.RecoveryTool(ctx, result.Evidence.ModuleDir, filepath.Join(common, "releasekit", "publication-recovery"))
if err != nil {
return "", err
}
if len(args) > 0 {
command := "env GOWORK=off GOTOOLCHAIN=local go"
for _, arg := range args {
command += " " + shellQuote(git.DisplayDiagnostic(arg))
}
path, err := filepath.Abs(git.DisplayDiagnostic(result.RecordPath))
if err != nil {
return "", err
}
return command + " reconcile --record " + shellQuote(path), nil
}
}
return publicationRecoveryCommand(result.RecordPath)
}
func publicationOffersRecovery(phase releasekit.PublicationPhase) bool {
switch phase {
case releasekit.PublicationTagCreation, releasekit.PublicationPush, releasekit.PublicationReconciliation:
return true
default:
return false
}
}
func publicationRecoveryCommand(recordPath string) (string, error) {
executable, err := os.Executable()
if err != nil {
return "", fmt.Errorf("find current executable: %w", err)
}
executable, err = filepath.Abs(executable)
if err != nil {
return "", fmt.Errorf("resolve current executable path: %w", err)
}
if gotool.TemporaryExecutable(executable) {
return "", fmt.Errorf("current executable belongs to a temporary Go build; select a supported ReleaseKit CLI for the saved record")
}
// Redact before path cleaning can change a pasted URL's recognizable syntax.
recordPath, err = filepath.Abs(git.DisplayDiagnostic(recordPath))
if err != nil {
return "", fmt.Errorf("resolve publication record path: %w", err)
}
return shellQuote(executable) + " reconcile --record " + shellQuote(recordPath), nil
}
func shellQuote(argument string) string {
return "'" + strings.ReplaceAll(argument, "'", "'\"'\"'") + "'"
}
func runBuild(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
if settings.Build == nil {
fmt.Fprintln(stderr, "build requires build settings in the profile")
return 2
}
result, err := releasekit.Build(ctx, releasekit.BuildRequest{
RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build),
OutputDir: invocation.OutputDir,
Output: stderr,
})
if result.OutputDir != "" && len(result.Targets) != 0 {
if receiptErr := cli.RemoveBuildReceipt(result.OutputDir); receiptErr != nil {
fmt.Fprintf(stderr, "%v\n", receiptErr)
return 1
}
}
if err != nil {
fmt.Fprintf(stderr, "build failed module=%s module-dir=%s version=%s tag=%s commit=%s: %v\n", result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, err)
for _, artifact := range result.Artifacts {
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
}
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
return 1
}
if len(result.Targets) == 0 {
fmt.Fprintf(stdout, "no build targets selected for module %s version %s tag %s at %s\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit)
return 0
}
receipt := receiptFromBuild(result)
receiptPath, err := cli.WriteBuildReceipt(result.OutputDir, receipt)
if err != nil {
fmt.Fprintf(stderr, "write build receipt: %v\n", err)
return 1
}
stored, err := cli.LoadBuildReceipt(receiptPath)
if err != nil {
fmt.Fprintf(stderr, "verify build receipt: %v\n", err)
return 1
}
if !stored.MatchesBuild(result.RepositoryDir, result.CandidateCommit, result.Version, *settings.Build) {
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
return 1
}
fmt.Fprintf(stdout, "built %d artifacts for module %s version %s tag %s at %s with %s\n", len(result.Artifacts), result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.Compiler)
for _, artifact := range result.Artifacts {
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
}
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
return 0
}
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 {
request := releasekit.ReleaseRequest{ request := releasekit.ReleaseRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
@@ -426,20 +528,7 @@ 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)
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
var preflightErr *releasekit.DeliveryPreflightError
if errors.As(err, &preflightErr) {
return deliveryPreflightExitStatus(preflightErr)
}
if result.State == releasekit.DeliveryUnknown {
return 3
}
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) 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 != "" { if result.ReleaseURL != "" {
@@ -449,6 +538,29 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
return 0 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) {
return 2
}
var preflightErr *releasekit.DeliveryPreflightError
if errors.As(err, &preflightErr) {
return deliveryPreflightExitStatus(preflightErr)
}
if result.State == releasekit.DeliveryUnknown {
return 3
}
return 1
}
func deliveryPreflightExitStatus(err error) int { func deliveryPreflightExitStatus(err error) int {
var preflightErr *releasekit.DeliveryPreflightError var preflightErr *releasekit.DeliveryPreflightError
if !errors.As(err, &preflightErr) { if !errors.As(err, &preflightErr) {

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)
@@ -465,6 +513,26 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing module . version v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) { if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing module . version v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) {
t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String()) t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String())
} }
const recordPrefix = "publication record saved: "
var recordPath string
for _, line := range strings.Split(stderr.String(), "\n") {
if strings.HasPrefix(line, recordPrefix) {
recordPath = strings.TrimPrefix(line, recordPrefix)
}
}
if !filepath.IsAbs(recordPath) {
t.Fatalf("default publication record path = %q, stderr = %q", recordPath, stderr.String())
}
recorded, err := releasekit.LoadPublicationEvidence(recordPath)
if err != nil {
t.Fatalf("load default publication record: %v", err)
}
if recorded.Tag != "v1.2.3" || recorded.CandidateCommit != commit || recorded.TagObject != tagObject || recorded.Endpoint != remote {
t.Fatalf("default publication evidence = %#v", recorded)
}
if strings.Contains(stderr.String(), "recovery command:") {
t.Fatalf("successful publication prescribed a recovery command: %q", stderr.String())
}
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject { if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject {
t.Fatalf("remote tag object = %s, want %s", got, tagObject) t.Fatalf("remote tag object = %s, want %s", got, tagObject)
} }
@@ -564,6 +632,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 +1104,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

@@ -2,11 +2,13 @@ package main
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
) )
@@ -80,20 +82,13 @@ func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) {
set -eu set -eu
printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG" printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG"
modfile= modfile=
output=
previous=
for argument in "$@"; do for argument in "$@"; do
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
if [ "$previous" = -o ]; then output=$argument; fi
previous=$argument
done done
if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi
if [ "$1" = build ]; then if [ "$1" = tool ]; then
cat > "$output" <<'TOOL' shift 3
#!/bin/sh printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS"
printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS"
TOOL
chmod +x "$output"
fi fi
` `
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil { if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
@@ -134,7 +129,7 @@ fi
} }
} }
func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) { func TestMultiModuleLauncherRunsToolFromSelectedDependency(t *testing.T) {
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module") example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
repository := newCLIRepository(t) repository := newCLIRepository(t)
copyExampleTree(t, example, repository) copyExampleTree(t, example, repository)
@@ -161,27 +156,50 @@ func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3") for _, status := range []int{0, 1, 2, 3} {
output, err := command.CombinedOutput() t.Run(strconv.Itoa(status), func(t *testing.T) {
if err != nil { command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3")
t.Fatalf("launcher error: %v output=%q", err, output) command.Env = setEnvironment(os.Environ(), "RELEASEKIT_TOOL_EXIT", strconv.Itoa(status))
} output, err := command.CombinedOutput()
for _, expected := range []string{ if status == 0 && err != nil {
"tool-version=v0.2.198 helper=tool-only dependency", t.Fatalf("launcher error: %v output=%q", err, output)
`"publish"`, `"--repo"`, `"` + repository + `"`, }
`"--config"`, `"releasekit-ckconfig.json"`, if status != 0 {
`"--expected-module"`, `"integrations/ckconfig"`, `"v1.2.3"`, var exitError *exec.ExitError
} { if !errors.As(err, &exitError) || exitError.ExitCode() != status {
if !strings.Contains(string(output), expected) { t.Fatalf("launcher error = %v, want exit %d; output=%q", err, status, output)
t.Errorf("launcher output %q omits %q", output, expected) }
} }
} for _, expected := range []string{
if strings.Contains(string(output), "tool-version=v0.2.197") { "tool-version=v0.2.198 helper=tool-only dependency",
t.Fatalf("launcher built the root-selected ReleaseKit version: %s", output) `"publish"`, `"--repo"`, `"` + repository + `"`,
} `"--config"`, `"releasekit-ckconfig.json"`,
moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod")) `"--expected-module"`, `"integrations/ckconfig"`, `"v1.2.3"`,
if err != nil || !bytes.Equal(moduleAfter, moduleBefore) { } {
t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err) if !strings.Contains(string(output), expected) {
t.Errorf("launcher output %q omits %q", output, expected)
}
}
if strings.Contains(string(output), "tool-version=v0.2.197") {
t.Fatalf("launcher ran the root-selected ReleaseKit version: %s", output)
}
moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
if err != nil || !bytes.Equal(moduleAfter, moduleBefore) {
t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err)
}
if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" {
t.Fatalf("launcher changed source: %q", status)
}
entries, err := os.ReadDir(filepath.Dir(repository))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
t.Fatalf("temporary bootstrap directory remains: %s", entry.Name())
}
}
})
} }
} }

View File

@@ -0,0 +1,520 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
"go.maximumdirect.net/releasekit/internal/gotool"
"go.maximumdirect.net/releasekit/internal/publish"
)
func TestVendoredAPIOnlyPublicationRecovery(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture executes POSIX recovery commands")
}
repo, remote := newSimplePublishRepository(t, false)
source := repositoryRoot(t)
relative, err := filepath.Rel(repo, source)
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repo, "go.mod", "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => "+strconv.Quote(relative)+"\n")
writeRepositoryFile(t, repo, "uses_releasekit.go", "package candidate\nimport _ \"go.maximumdirect.net/releasekit\"\n")
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repo, "go.sum", string(sums))
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
for _, args := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} {
command := exec.Command(goBinary, args...)
command.Dir = repo
if out, err := command.CombinedOutput(); err != nil {
t.Fatalf("prepare vendored API: %v: %s", err, out)
}
}
cliGitRun(t, repo, "add", ".")
cliGitRun(t, repo, "commit", "-m", "Vendor API dependency")
cliGitRun(t, repo, "push", "origin", "main")
before := cliGitOutput(t, repo, "rev-parse", "HEAD^{tree}")
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
launcherStore := t.TempDir()
launcherArgs, err := gotool.RecoveryTool(t.Context(), repo, launcherStore)
if err != nil {
t.Fatal(err)
}
launcher := exec.Command(goBinary, append(launcherArgs, "publish", "--repo", repo, "v1.2.3")...)
launcher.Dir = t.TempDir()
publicationOutput, err := launcher.CombinedOutput()
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("vendored publication: %v: %s", err, publicationOutput)
}
// The later handoff owns different launch metadata from the original driver.
if err := os.RemoveAll(launcherStore); err != nil {
t.Fatal(err)
}
command := outputLineValue(string(publicationOutput), "recovery command: ")
if command == "" {
t.Fatalf("no handoff: %s", publicationOutput)
}
recovery := exec.Command("sh", "-c", command)
recovery.Dir = t.TempDir()
recovered, err := recovery.CombinedOutput()
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 || !strings.Contains(string(recovered), "module=example.com/cli-candidate tag=v1.2.3 state=not_published") {
t.Fatalf("vendored recovery: %v: %s", err, recovered)
}
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("handoff changed consumer files: %s", status)
}
if after := cliGitOutput(t, repo, "write-tree"); after != before {
t.Fatalf("handoff changed consumer index: %s != %s", after, before)
}
}
func TestRecoveryToolPreservesVersionedReplacement(t *testing.T) {
proxy := writeReleaseKitModuleProxy(t)
environment := make(map[string]string)
useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy))
for key, value := range environment {
t.Setenv(key, value)
}
module := t.TempDir()
data := "module example.com/recovery-consumer\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n\nreplace go.maximumdirect.net/releasekit v0.2.197 => go.maximumdirect.net/releasekit v0.2.198\n"
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte(data), 0600); err != nil {
t.Fatal(err)
}
args, err := gotool.RecoveryTool(t.Context(), module, t.TempDir())
if err != nil {
t.Fatal(err)
}
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...)
command.Dir = t.TempDir()
output, err := command.CombinedOutput()
if err != nil || !strings.Contains(string(output), "tool-version=v0.2.198 helper=tool-only dependency") {
t.Fatalf("versioned recovery selection: %v: %s", err, output)
}
after, err := os.ReadFile(filepath.Join(module, "go.mod"))
if err != nil || string(after) != data {
t.Fatalf("consumer metadata changed: %q, %v", after, err)
}
}
func TestRecoveryToolSupportsReleaseKitSourceModule(t *testing.T) {
args, err := gotool.RecoveryTool(t.Context(), repositoryRoot(t), t.TempDir())
if err != nil {
t.Fatal(err)
}
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args, "version")...)
command.Dir = t.TempDir()
output, err := command.CombinedOutput()
if err != nil || !strings.HasPrefix(string(output), "releasekit ") {
t.Fatalf("source module recovery entry point: %v: %s", err, output)
}
}
func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("fixture observes the cold executable through /proc")
}
for _, mode := range []struct{ registered, canceled bool }{{true, false}, {false, false}, {true, true}, {false, true}} {
registered := mode.registered
t.Run(fmt.Sprintf("registered=%t/canceled=%t", registered, mode.canceled), func(t *testing.T) {
repo, remote := newSimplePublishRepository(t, false)
source := repositoryRoot(t)
// A distinct replacement identity gives this tool a fresh link action
// while retaining the shared compilation cache for its dependencies.
selectedSource := filepath.Join(t.TempDir(), "ReleaseKit source")
if err := os.Symlink(source, selectedSource); err != nil {
t.Fatal(err)
}
if !registered {
relative, err := filepath.Rel(repo, selectedSource)
if err != nil {
t.Fatal(err)
}
selectedSource = relative
}
module := "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => " + strconv.Quote(selectedSource) + "\n"
if registered {
module += "\ntool go.maximumdirect.net/releasekit/cmd/releasekit\n"
}
writeRepositoryFile(t, repo, "go.mod", module)
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repo, "go.sum", string(sums))
if !registered {
for _, name := range []string{"release.sh", "select-module.sh"} {
data, err := os.ReadFile(filepath.Join(source, "examples", "multi-module", name))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, name), data, 0700); err != nil {
t.Fatal(err)
}
}
writeRepositoryFile(t, repo, "release-modules.tsv", "root\tv*\t.\treleasekit.json\n")
}
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
if registered {
prepare := exec.Command(goBinary, "list", "-mod=mod", "-deps", "go.maximumdirect.net/releasekit/cmd/releasekit")
prepare.Dir = repo
if out, err := prepare.CombinedOutput(); err != nil {
t.Fatalf("prepare tool: %v: %s", err, out)
}
}
cliGitRun(t, repo, "add", ".")
cliGitRun(t, repo, "commit", "-m", "Select release tool")
cliGitRun(t, repo, "push", "origin", "main")
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
executed := filepath.Join(t.TempDir(), "executed-tool")
wrapper := "#!/bin/sh\nparent=$(readlink \"/proc/$PPID/exe\")\ncase \"$parent\" in */exe/releasekit) printf '%s' \"$parent\" > " + shellQuote(executed) + ";; esac\n" + cancelPushScript + "exec " + shellQuote(realGit) + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
var launch *exec.Cmd
if registered {
launch = exec.Command(goBinary, "tool", "releasekit", "publish", "--repo", repo, "v1.2.3")
} else {
launch = exec.Command(filepath.Join(repo, "release.sh"), "root", "v1.2.3")
}
launch.Dir = repo
var out []byte
if mode.canceled {
out, err = canceledPublicationOutput(t, launch, repo, "")
} else {
out, err = launch.CombinedOutput()
}
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("publication: %v: %s", err, out)
}
text := string(out)
launchedPath, err := os.ReadFile(executed)
if err != nil {
t.Fatalf("did not exercise a cold temporary executable: %v: %s", err, text)
}
if _, err := os.Stat(string(launchedPath)); !os.IsNotExist(err) {
t.Fatalf("cold executable still exists: %s: %v", launchedPath, err)
}
entries, err := os.ReadDir(filepath.Dir(repo))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
t.Fatalf("launcher bootstrap survived: %s", entry.Name())
}
}
command := outputLineValue(text, "recovery command: ")
if !strings.Contains(command, "go ") || strings.Contains(command, "/exe/releasekit") {
t.Fatalf("unstable handoff: %s", command)
}
if !registered && !strings.Contains(command, "publication-recovery") {
t.Fatalf("API-only handoff lacks durable metadata: %s", command)
}
// Execute from another directory after the original driver and the
// API-only launcher's temporary files are gone.
evidence, err := publish.LoadEvidence(outputLineValue(text, "publication record saved: "))
if err != nil {
t.Fatal(err)
}
evidence.Endpoint = filepath.Join(t.TempDir(), "unavailable.git")
unknownRecord, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
if err != nil {
t.Fatal(err)
}
for _, scenario := range []struct {
record string
status int
}{{"", 1}, {filepath.Join(t.TempDir(), "missing.json"), 2}, {unknownRecord, 3}, {"", 0}} {
if scenario.status == 0 {
if err := os.Remove(filepath.Join(remote, "hooks", "pre-receive")); err != nil {
t.Fatal(err)
}
cliGitRun(t, repo, "push", "origin", "refs/tags/v1.2.3")
}
selected := command
if scenario.record != "" {
selected += " --record " + shellQuote(scenario.record)
}
recovery := exec.Command("sh", "-c", selected)
recovery.Dir = t.TempDir()
recovered, err := recovery.CombinedOutput()
if scenario.status == 0 && err != nil || scenario.status != 0 && (!errors.As(err, &exitError) || exitError.ExitCode() != scenario.status) {
t.Fatalf("recovery status want %d: %v: %s", scenario.status, err, recovered)
}
if scenario.status == 1 && !strings.Contains(string(recovered), "state=not_published") {
t.Fatalf("wrong observation: %s", recovered)
}
}
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("handoff modified candidate: %s", status)
}
})
}
}
func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) {
repository, _ := newSimplePublishRepository(t, true)
var stdout, stderr bytes.Buffer
status := run(t.Context(), []string{"publish", "--repo", repository, "--record-dir", "record-blocker", "v1.2.3"}, &stdout, &stderr)
if status != 0 || !strings.Contains(stdout.String(), "published v1.2.3") {
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "publication record warning:") || strings.Contains(stderr.String(), "publication record saved:") || strings.Contains(stderr.String(), "recovery command:") {
t.Fatalf("recording warning or success handoff output = %q", stderr.String())
}
}
func TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts(t *testing.T) {
for _, mode := range []string{"rejected", "canceled", "preparation failure"} {
t.Run(mode, func(t *testing.T) {
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
t.Setenv("GIT_TERMINAL_PROMPT", "0")
repository, remote := newSimplePublishRepository(t, false)
store := filepath.Join(t.TempDir(), "attempt records with ' apostrophe")
hook := filepath.Join(remote, "hooks", "pre-receive")
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil {
t.Fatal(err)
}
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
executable := filepath.Join(t.TempDir(), "releasekit's CLI")
build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit")
build.Dir = repositoryRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build CLI: %v\n%s", err, output)
}
if mode == "canceled" {
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
if err := os.WriteFile(filepath.Join(bin, "git"), []byte("#!/bin/sh\n"+cancelPushScript+"exec "+shellQuote(realGit)+" \"$@\"\n"), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
}
if mode == "preparation failure" {
realGo, err := exec.LookPath("go")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
script := "#!/bin/sh\nif [ \"$1\" = list ] && [ \"$2\" = -e ]; then exit 73; fi\nexec " + shellQuote(realGo) + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
}
command := exec.Command(executable, "publish", "--repo", repository, "--record-dir", store, "v1.2.3")
command.Dir = repositoryRoot(t)
var output []byte
var err error
if mode == "canceled" {
output, err = canceledPublicationOutput(t, command, repository, store)
} else {
output, err = command.CombinedOutput()
}
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("publication exit = %v, output = %q; want status 1", err, output)
}
text := string(output)
if mode == "preparation failure" && !strings.Contains(text, "recovery tool preparation warning:") {
t.Fatalf("preparation warning missing: %s", text)
}
if !strings.Contains(text, "publication state=not_published phase=reconciliation") {
t.Fatalf("publication failure classification changed: %q", text)
}
recordPath := outputLineValue(text, "publication record saved: ")
if !filepath.IsAbs(recordPath) || !strings.Contains(recordPath, "attempt records with ' apostrophe") {
t.Fatalf("saved record path = %q, output = %q", recordPath, text)
}
recoveryCommand := outputLineValue(text, "recovery command: ")
if recoveryCommand == "" || !strings.Contains(recoveryCommand, shellQuote(executable)) || !strings.Contains(recoveryCommand, shellQuote(recordPath)) {
t.Fatalf("copyable recovery command = %q, output = %q", recoveryCommand, text)
}
if !strings.Contains(text, "selected supported ReleaseKit CLI") {
t.Fatalf("output omits guidance for an unavailable original executable: %q", text)
}
for _, decoy := range []publish.Evidence{
{RepositoryDir: repository, ModulePath: "example.com/other-module", Version: "v8.8.8", Tag: "v8.8.8", Endpoint: remote, Remote: "origin"},
{RepositoryDir: repository, ModulePath: "example.com/retry", Version: "v1.2.4", Tag: "v1.2.4", Endpoint: remote, Remote: "origin"},
} {
if _, err := publish.SaveEvidence(t.Context(), repository, store, decoy); err != nil {
t.Fatal(err)
}
}
recovery := exec.Command("/bin/sh", "-c", recoveryCommand)
recovery.Dir = t.TempDir()
recoveryOutput, recoveryErr := recovery.CombinedOutput()
if !errors.As(recoveryErr, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("recovery command exit = %v, output = %q; want known absence status 1", recoveryErr, recoveryOutput)
}
recoveryText := string(recoveryOutput)
if !strings.Contains(recoveryText, "module=example.com/cli-candidate tag=v1.2.3 state=not_published") || strings.Contains(recoveryText, "example.com/other-module") || strings.Contains(recoveryText, "example.com/retry") {
t.Fatalf("printed command selected the wrong attempt: %q", recoveryText)
}
})
}
}
func TestShellQuoteHandlesApostrophesAndSpaces(t *testing.T) {
got := shellQuote("/tmp/ReleaseKit's recovery record.json")
if got != "'/tmp/ReleaseKit'\"'\"'s recovery record.json'" {
t.Fatalf("shellQuote() = %q", got)
}
}
func newSimplePublishRepository(t *testing.T, recordBlocker bool) (string, string) {
t.Helper()
repository := newCLIRepository(t)
settings, err := json.Marshal(cli.Settings{Product: "Example Product", Remote: "origin"})
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(settings))
files := []string{"releasekit.json"}
if recordBlocker {
writeRepositoryFile(t, repository, "record-blocker", "tracked file\n")
files = append(files, "record-blocker")
}
cliGitRun(t, repository, append([]string{"add"}, files...)...)
cliGitRun(t, repository, "commit", "-m", "configure source release")
remote := filepath.Join(t.TempDir(), "origin.git")
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
return repository, remote
}
func outputLineValue(output, prefix string) string {
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, prefix) {
return strings.TrimPrefix(line, prefix)
}
}
return ""
}
func repositoryRoot(t *testing.T) string {
t.Helper()
directory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return filepath.Clean(filepath.Join(directory, "..", ".."))
}
// The shim blocks exactly when Git would start the recorded tag push. The CLI
// PID lets the test cancel the actual operation even behind a Go driver/launcher.
const cancelPushScript = `if [ -n "$RELEASEKIT_CANCEL_PUSH_READY" ]; then
for arg in "$@"; do
if [ "$arg" = push ]; then
printf '%s' "$PPID" > "$RELEASEKIT_CANCEL_PUSH_READY"
exec sleep 600
fi
done
fi
`
func canceledPublicationOutput(t *testing.T, command *exec.Cmd, repo, store string) ([]byte, error) {
t.Helper()
ready := filepath.Join(t.TempDir(), "push-ready")
command.Env = append(os.Environ(), "RELEASEKIT_CANCEL_PUSH_READY="+ready)
var output bytes.Buffer
command.Stdout = &output
command.Stderr = &output
if err := command.Start(); err != nil {
t.Fatal(err)
}
defer command.Process.Kill()
done := make(chan error, 1)
go func() { done <- command.Wait() }()
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
var operation *os.Process
waiting:
for {
select {
case err := <-done:
t.Fatalf("publication finished before push interception: %v: %s", err, &output)
case <-ctx.Done():
_ = command.Process.Kill()
<-done
t.Fatal("publication never reached push interception")
case <-ticker.C:
data, err := os.ReadFile(ready)
if err != nil {
continue
}
pid, err := strconv.Atoi(string(data))
if err != nil {
continue
}
operation, err = os.FindProcess(pid)
if err != nil {
t.Fatal(err)
}
defer operation.Release()
break waiting
}
}
// Check the handoff identity before allowing cancellation to reach publication.
listed, err := publish.ListAttempts(t.Context(), repo, store)
if err != nil || len(listed.Attempts) != 1 || listed.Attempts[0].Evidence.TagObject == "" {
_ = operation.Signal(os.Interrupt)
<-done
t.Fatalf("pre-push record is incomplete: %#v, %v", listed, err)
}
if err := operation.Signal(os.Interrupt); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if !strings.Contains(output.String(), "context canceled") {
t.Fatalf("publication lost its cancellation diagnostic: %s", &output)
}
return output.Bytes(), err
case <-ctx.Done():
_ = operation.Kill()
_ = command.Process.Kill()
<-done
t.Fatal("canceled publication did not return")
}
return nil, nil
}

View File

@@ -0,0 +1,351 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"go.maximumdirect.net/releasekit"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
)
func TestPublicationPushGateHelper(t *testing.T) {
address := os.Getenv("RELEASEKIT_PUSH_GATE_ADDR")
if address == "" {
return
}
connection, err := net.DialTimeout("tcp", address, 10*time.Second)
if err != nil {
t.Fatalf("connect to pre-receive gate: %v", err)
}
defer connection.Close()
if _, err := fmt.Fprintln(connection, "ready"); err != nil {
t.Fatalf("announce pre-receive readiness: %v", err)
}
response, err := bufio.NewReader(connection).ReadString('\n')
if err != nil || strings.TrimSpace(response) != "reject" {
t.Fatalf("pre-receive gate response = %q, %v", response, err)
}
// The test process is the pre-receive hook's gate. Reject only after the
// parent has observed the pre-push record and explicitly released it.
t.Fatal("controlled rejection after pre-push record observation")
}
func TestLostPublicationOutputCanBeRecoveredFromExplicitRecords(t *testing.T) {
setIsolatedGitEnvironment(t)
repository := newCLIRepository(t)
store := filepath.Join(t.TempDir(), "publication records")
checkLog := filepath.Join(t.TempDir(), "candidate checks.log")
var hostedRequests atomic.Int64
hostedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hostedRequests.Add(1)
w.WriteHeader(http.StatusNotFound)
}))
defer hostedServer.Close()
settings := cli.Settings{
Product: "Example Product",
Remote: "origin",
Checks: []cli.CheckCommand{{
Args: []string{"/bin/sh", "-c", "printf 'candidate\\n' >> \"$RELEASEKIT_CANDIDATE_LOG\""},
Env: map[string]string{"RELEASEKIT_CANDIDATE_LOG": checkLog},
}},
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: hostedServer.URL, Owner: "example", Repository: "candidate"},
}
writeSettings := func() {
t.Helper()
contents, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(contents))
cliGitRun(t, repository, "add", "releasekit.json")
cliGitRun(t, repository, "commit", "-m", "update publication profile")
}
writeSettings()
remote := filepath.Join(t.TempDir(), "original.git")
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
nestedDir := "integrations/recovery"
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "go.mod")), "module example.com/cli-candidate/recovery\n\ngo 1.25.0\n")
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "candidate_test.go")), "package recovery\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "docs/releases/v1.2.3.md")), "# Example Product v1.2.3\n\nRecovery fixture.\n")
cliGitRun(t, repository, "add", nestedDir)
cliGitRun(t, repository, "commit", "-m", "add nested module")
cliGitRun(t, repository, "push", "origin", "main")
installPushGate(t, remote)
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
executable := filepath.Join(t.TempDir(), "releasekit recovery workflow")
build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit")
build.Dir = repositoryRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build CLI: %v\n%s", err, output)
}
testBinary, err := os.Executable()
if err != nil {
t.Fatal(err)
}
rootTag := "v1.2.3"
rootRecord := rejectPublicationAfterRecording(t, executable, testBinary, repository, store, rootTag, "")
if rootRecord.Evidence.ModuleDir != repository || rootRecord.Evidence.Tag != rootTag {
t.Fatalf("root attempt evidence = %#v", rootRecord.Evidence)
}
settings.ModuleDir = nestedDir
writeSettings()
cliGitRun(t, repository, "push", "origin", "main")
nestedTag := nestedDir + "/v1.2.3"
nestedRecord := rejectPublicationAfterRecording(t, executable, testBinary, repository, store, nestedTag, nestedDir)
if nestedRecord.Evidence.ModuleDir != filepath.Join(repository, nestedDir) || nestedRecord.Evidence.Tag != nestedTag {
t.Fatalf("nested attempt evidence = %#v", nestedRecord.Evidence)
}
if rootRecord.RecordPath == nestedRecord.RecordPath {
t.Fatalf("publication attempts reused record path %q", rootRecord.RecordPath)
}
// Replace the rejected root tag on the original endpoint with a different
// annotated object, then make the current checkout point somewhere else.
hook := filepath.Join(remote, "hooks", "pre-receive")
if err := os.Remove(hook); err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "tag", "-f", "-a", rootTag, "-m", "remote replacement after failed attempt", "HEAD")
cliGitRun(t, repository, "push", "origin", "refs/tags/"+rootTag)
decoyRemote := filepath.Join(t.TempDir(), "replacement-origin.git")
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", decoyRemote)
cliGitRun(t, repository, "remote", "set-url", "origin", decoyRemote)
cliGitRun(t, repository, "switch", "-c", "recovery-inspection")
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "move recovery checkout")
cliGitRun(t, repository, "tag", "-d", rootTag, nestedTag)
writeRepositoryFile(t, repository, "untracked recovery marker", "preserve this file\n")
before := snapshotRecoveryRepository(t, repository, remote, rootRecord.RecordPath, nestedRecord.RecordPath)
listing := runCLIJSON(t, executable, t.TempDir(), "publication-attempts", "--repo", repository, "--record-dir", store, "--json")
var listed struct {
Attempts []struct {
RecordPath string `json:"record_path"`
Evidence struct {
Tag string `json:"tag"`
ModuleDir string `json:"module_dir"`
} `json:"evidence"`
} `json:"attempts"`
}
if err := json.Unmarshal(listing, &listed); err != nil {
t.Fatalf("decode publication-attempts output: %v\n%s", err, listing)
}
selectedRoot := ""
selectedNested := ""
for _, attempt := range listed.Attempts {
switch attempt.Evidence.Tag {
case rootTag:
selectedRoot = attempt.RecordPath
case nestedTag:
selectedNested = attempt.RecordPath
}
}
if selectedRoot != rootRecord.RecordPath || selectedNested != nestedRecord.RecordPath {
t.Fatalf("discovery selected root=%q nested=%q; want %q and %q; listing=%s", selectedRoot, selectedNested, rootRecord.RecordPath, nestedRecord.RecordPath, listing)
}
rootResult := runCLIStatusJSON(t, executable, t.TempDir(), "reconcile", "--record", selectedRoot, "--json")
if rootResult.status != 1 || rootResult.document["state"] != "conflict" {
t.Fatalf("explicit root reconciliation status=%d document=%s; want conflict on the recorded original endpoint", rootResult.status, rootResult.output)
}
nestedResult := runCLIStatusJSON(t, executable, t.TempDir(), "reconcile", "--record", selectedNested, "--json")
if nestedResult.status != 1 || nestedResult.document["state"] != "not_published" {
t.Fatalf("explicit nested reconciliation status=%d document=%s; want not_published", nestedResult.status, nestedResult.output)
}
if got := hostedRequests.Load(); got != 0 {
t.Fatalf("later recovery made %d hosted delivery requests", got)
}
after := snapshotRecoveryRepository(t, repository, remote, rootRecord.RecordPath, nestedRecord.RecordPath)
if before != after {
t.Fatalf("reconciliation changed local or saved state:\nbefore: %#v\nafter: %#v", before, after)
}
checkBytes, err := os.ReadFile(checkLog)
if err != nil {
t.Fatal(err)
}
if got := strings.Count(string(checkBytes), "candidate\n"); got != 2 {
t.Fatalf("candidate checks ran %d times; want once per publication and none during recovery: %q", got, checkBytes)
}
}
type listedAttempt struct {
RecordPath string
Evidence releasekit.PublicationEvidence
}
func rejectPublicationAfterRecording(t *testing.T, executable, testBinary, repository, store, tag, expectedModule string) listedAttempt {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
commandArgs := []string{"publish", "--repo", repository, "--record-dir", store}
if expectedModule != "" {
commandArgs = append(commandArgs, "--expected-module", expectedModule)
}
commandArgs = append(commandArgs, "v1.2.3")
command := exec.Command(executable, commandArgs...)
command.Dir = repositoryRoot(t)
command.Env = append(os.Environ(), "RELEASEKIT_PUSH_GATE_ADDR="+listener.Addr().String(), "RELEASEKIT_PUSH_GATE_TEST_BINARY="+testBinary)
command.Stdout = io.Discard
command.Stderr = io.Discard
if err := command.Start(); err != nil {
t.Fatal(err)
}
defer func() {
if command.Process != nil {
_ = command.Process.Kill()
_ = command.Wait()
}
}()
if tcpListener, ok := listener.(*net.TCPListener); ok {
if err := tcpListener.SetDeadline(time.Now().Add(90 * time.Second)); err != nil {
t.Fatal(err)
}
}
connection, err := listener.Accept()
if err != nil {
t.Fatalf("wait for pre-receive gate for %s: %v", tag, err)
}
defer connection.Close()
if tcpConnection, ok := connection.(*net.TCPConn); ok {
if err := tcpConnection.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
t.Fatal(err)
}
}
line, err := bufio.NewReader(connection).ReadString('\n')
if err != nil || strings.TrimSpace(line) != "ready" {
t.Fatalf("pre-receive gate readiness for %s = %q, %v", tag, line, err)
}
listed, err := releasekit.ListPublicationAttempts(context.Background(), releasekit.PublicationAttemptsRequest{RepositoryDir: repository, RecordDir: store})
if err != nil {
t.Fatalf("list attempts while tag push is held: %v", err)
}
var selected *releasekit.PublicationAttempt
for i := range listed.Attempts {
if listed.Attempts[i].Evidence.Tag == tag {
selected = &listed.Attempts[i]
break
}
}
if selected == nil {
t.Fatalf("pre-push record for %s was not visible before the hook continued: %#v", tag, listed.Attempts)
}
if selected.Evidence.CandidateCommit == "" || selected.Evidence.TagObject == "" || selected.Evidence.Endpoint == "" {
t.Fatalf("pre-push evidence for %s is incomplete: %#v", tag, selected.Evidence)
}
if _, err := fmt.Fprintln(connection, "reject"); err != nil {
t.Fatalf("release pre-receive gate for %s: %v", tag, err)
}
waitErr := command.Wait()
command.Process = nil
if waitErr == nil {
t.Fatalf("publish %s succeeded despite the controlled rejection", tag)
} else {
var exitError *exec.ExitError
if !errors.As(waitErr, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("publish %s exit = %v; want publication failure status 1", tag, waitErr)
}
}
return listedAttempt{RecordPath: selected.RecordPath, Evidence: selected.Evidence}
}
func installPushGate(t *testing.T, remote string) {
t.Helper()
hook := filepath.Join(remote, "hooks", "pre-receive")
script := "#!/bin/sh\nwhile read old new ref; do\n case \"$ref\" in\n refs/tags/*) \"$RELEASEKIT_PUSH_GATE_TEST_BINARY\" -test.run='^TestPublicationPushGateHelper$'; exit $? ;;\n esac\ndone\nexit 0\n"
if err := os.WriteFile(hook, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
}
func setIsolatedGitEnvironment(t *testing.T) {
t.Helper()
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
t.Setenv("GIT_TERMINAL_PROMPT", "0")
}
func runCLIJSON(t *testing.T, executable, directory string, args ...string) []byte {
t.Helper()
result := runCLIStatusJSON(t, executable, directory, args...)
if result.status != 0 {
t.Fatalf("CLI %v exited with status %d: %s", args, result.status, result.output)
}
return result.output
}
type cliJSONResult struct {
status int
output []byte
document map[string]any
}
func runCLIStatusJSON(t *testing.T, executable, directory string, args ...string) cliJSONResult {
t.Helper()
command := exec.Command(executable, args...)
command.Dir = directory
output, err := command.CombinedOutput()
status := 0
if err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
t.Fatalf("run CLI %v: %v\n%s", args, err, output)
}
status = exitError.ExitCode()
}
var document map[string]any
if err := json.Unmarshal(output, &document); err != nil {
t.Fatalf("decode CLI JSON for %v: %v\n%s", args, err, output)
}
return cliJSONResult{status: status, output: output, document: document}
}
type recoverySnapshot struct {
Branch string
Head string
Worktree string
LocalRefs string
RemoteRefs string
Records string
}
func snapshotRecoveryRepository(t *testing.T, repository, remote string, recordPaths ...string) recoverySnapshot {
t.Helper()
branch := cliGitOutput(t, repository, "branch", "--show-current")
head := cliGitOutput(t, repository, "rev-parse", "HEAD")
worktree := cliGitOutput(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
localRefs := cliGitOutput(t, repository, "show-ref")
remoteRefs := cliGitOutput(t, remote, "show-ref")
var records strings.Builder
for _, path := range recordPaths {
contents, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved record %s: %v", path, err)
}
fmt.Fprintf(&records, "%s\x00%s\n", path, contents)
}
return recoverySnapshot{Branch: branch, Head: head, Worktree: worktree, LocalRefs: localRefs, RemoteRefs: remoteRefs, Records: records.String()}
}

259
cmd/releasekit/recovery.go Normal file
View File

@@ -0,0 +1,259 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"go.maximumdirect.net/releasekit"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
"go.maximumdirect.net/releasekit/internal/git"
)
type recoveryDiagnostic struct {
Kind string `json:"kind"`
Message string `json:"message"`
RecordPath string `json:"record_path,omitempty"`
MissingFields []string `json:"missing_fields,omitempty"`
}
type safeEvidence struct {
RepositoryDir string `json:"repository_dir,omitempty"`
ModuleDir string `json:"module_dir,omitempty"`
ModulePath string `json:"module_path,omitempty"`
Version string `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
CandidateCommit string `json:"candidate_commit,omitempty"`
TagObject string `json:"tag_object,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Remote string `json:"remote,omitempty"`
RecordedAt string `json:"recorded_at,omitempty"`
}
type attemptDocument struct {
RecordPath string `json:"record_path"`
Evidence safeEvidence `json:"evidence"`
}
type reconciliationDocument struct {
SchemaVersion int `json:"schema_version"`
Command string `json:"command"`
RecordPath string `json:"record_path"`
Evidence *safeEvidence `json:"evidence,omitempty"`
State string `json:"state,omitempty"`
ObservedTagObject string `json:"observed_tag_object,omitempty"`
ObservedCommit string `json:"observed_commit,omitempty"`
Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"`
}
type attemptsDocument struct {
SchemaVersion int `json:"schema_version"`
Command string `json:"command"`
RecordDir string `json:"record_dir"`
Attempts []attemptDocument `json:"attempts"`
Diagnostics []*recoveryDiagnostic `json:"diagnostics"`
Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"`
}
func safe(e releasekit.PublicationEvidence) safeEvidence {
return safeEvidence{
RepositoryDir: git.DisplayDiagnostic(e.RepositoryDir),
ModuleDir: git.DisplayDiagnostic(e.ModuleDir),
ModulePath: git.DisplayDiagnostic(e.ModulePath),
Version: git.DisplayDiagnostic(e.Version),
Tag: git.DisplayDiagnostic(e.Tag),
CandidateCommit: git.DisplayDiagnostic(e.CandidateCommit),
TagObject: git.DisplayDiagnostic(e.TagObject),
Endpoint: git.DisplayDiagnostic(e.Endpoint),
Remote: git.DisplayDiagnostic(e.Remote),
RecordedAt: git.DisplayDiagnostic(e.RecordedAt),
}
}
func diagnostic(err error) *recoveryDiagnostic {
if err == nil {
return nil
}
var typed *releasekit.PublicationRecoveryError
if errors.As(err, &typed) {
missing := make([]string, len(typed.MissingFields))
for i, field := range typed.MissingFields {
missing[i] = git.DisplayDiagnostic(field)
}
return &recoveryDiagnostic{Kind: git.DisplayDiagnostic(typed.Kind), Message: git.DisplayError(err).Error(), RecordPath: git.DisplayDiagnostic(typed.RecordPath), MissingFields: missing}
}
return &recoveryDiagnostic{Kind: "invocation", Message: git.DisplayError(err).Error()}
}
func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
if invocation.Command == cli.ReconcileAction {
return runReconcile(ctx, invocation, stdout, stderr)
}
listed, err := releasekit.ListPublicationAttempts(ctx, releasekit.PublicationAttemptsRequest{RepositoryDir: invocation.Repository, RecordDir: invocation.RecordDir})
return reportAttempts(listed, err, invocation.JSON, stdout, stderr)
}
func reportAttempts(listed releasekit.PublicationAttemptsResult, err error, jsonOutput bool, stdout, stderr io.Writer) int {
if jsonOutput {
attempts := make([]attemptDocument, 0, len(listed.Attempts))
for _, item := range listed.Attempts {
attempts = append(attempts, attemptDocument{RecordPath: git.DisplayDiagnostic(item.RecordPath), Evidence: safe(item.Evidence)})
}
diagnostics := make([]*recoveryDiagnostic, 0, len(listed.Diagnostics))
for _, item := range listed.Diagnostics {
diagnostics = append(diagnostics, diagnostic(item))
}
if writeErr := writeJSON(stdout, attemptsDocument{
SchemaVersion: 1,
Command: "publication-attempts",
RecordDir: git.DisplayDiagnostic(listed.RecordDir),
Attempts: attempts,
Diagnostics: diagnostics,
Diagnostic: diagnostic(err),
}); writeErr != nil {
fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr))
return 1
}
} else {
fmt.Fprintf(stdout, "publication records in %s\n", git.DisplayDiagnostic(listed.RecordDir))
for _, item := range listed.Attempts {
fmt.Fprintf(stdout, "%s module=%s tag=%s commit=%s recorded-at=%s\n", git.DisplayDiagnostic(item.RecordPath), git.DisplayDiagnostic(item.Evidence.ModulePath), git.DisplayDiagnostic(item.Evidence.Tag), git.DisplayDiagnostic(item.Evidence.CandidateCommit), git.DisplayDiagnostic(item.Evidence.RecordedAt))
}
if err != nil || len(listed.Diagnostics) > 0 {
fmt.Fprintln(stderr, "discovery is incomplete")
}
for _, item := range listed.Diagnostics {
fmt.Fprintf(stderr, "record %s: %s\n", git.DisplayDiagnostic(item.RecordPath), humanDiagnostic(item))
}
if err != nil {
fmt.Fprintf(stderr, "discovery failed: %v\n", git.DisplayError(err))
}
}
if err != nil || len(listed.Diagnostics) > 0 {
return 1
}
return 0
}
func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
evidence, loadErr := releasekit.LoadPublicationEvidence(invocation.RecordPath)
var result releasekit.PublicationReconciliationResult
err := loadErr
if err == nil {
result, err = releasekit.ReconcilePublication(ctx, evidence)
}
status := reconciliationStatus(result.State, err)
if invocation.JSON {
dto := reconciliationDocument{SchemaVersion: 1, Command: "reconcile", RecordPath: git.DisplayDiagnostic(invocation.RecordPath)}
if loadErr == nil {
safeEvidence := safe(evidence)
dto.Evidence = &safeEvidence
dto.State = string(result.State)
dto.ObservedTagObject = result.ObservedTagObject
dto.ObservedCommit = result.ObservedCommit
}
dto.Diagnostic = diagnostic(err)
if writeErr := writeJSON(stdout, dto); writeErr != nil {
fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr))
return 1
}
} else if err == nil {
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State)
if result.State == releasekit.PublicationConflict {
fmt.Fprintf(stdout, "expected tag-object=%s commit=%s; observed tag-object=%s commit=%s\n", git.DisplayDiagnostic(evidence.TagObject), git.DisplayDiagnostic(evidence.CandidateCommit), result.ObservedTagObject, result.ObservedCommit)
} else if result.ObservedTagObject != "" || result.ObservedCommit != "" {
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
}
if guidance := reconciliationGuidance(result.State); guidance != "" {
fmt.Fprintf(stdout, "%s\n", guidance)
}
}
if err != nil && !invocation.JSON {
if loadErr == nil {
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State)
if result.ObservedTagObject != "" || result.ObservedCommit != "" {
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
}
}
fmt.Fprintf(stderr, "reconcile %s: %s\n", git.DisplayDiagnostic(invocation.RecordPath), humanDiagnostic(err))
if guidance := recoveryGuidance(err); guidance != "" {
fmt.Fprintf(stderr, "%s\n", guidance)
}
}
return status
}
func reconciliationStatus(state releasekit.PublicationState, err error) int {
if err == nil {
switch state {
case releasekit.PublicationPublished:
return 0
case releasekit.PublicationNotPublished, releasekit.PublicationConflict:
return 1
case releasekit.PublicationUnknown:
return 3
default:
return 2
}
}
var typed *releasekit.PublicationRecoveryError
if errors.As(err, &typed) {
switch typed.Kind {
case "record_read", "record_format":
return 2
default:
return 3
}
}
return 2
}
func recoveryGuidance(err error) string {
var typed *releasekit.PublicationRecoveryError
if !errors.As(err, &typed) {
return ""
}
switch typed.Kind {
case "missing_evidence":
return "Select a record with the missing identity fields, or provide complete typed evidence through the Go API."
case "invalid_evidence":
return "Use a record with a valid full tag and Git object ID."
case "endpoint_changed":
return "Review Git URL rewrite settings and restore the captured endpoint mapping before retrying."
case "transport_configuration":
return "Review Git configuration and the supported transport settings, then retry observation."
case "canceled":
return "Retry observation with a context that allows it to complete."
case "remote_observation", "inconsistent_observation":
return "Review the Git diagnostic and the remote tag refs before taking any corrective action."
default:
return ""
}
}
func reconciliationGuidance(state releasekit.PublicationState) string {
switch state {
case releasekit.PublicationNotPublished:
return "This reports current absence only; review publication history and version policy before retrying."
case releasekit.PublicationConflict:
return "Do not replace a conflicting remote tag; verify the observed IDs and follow the repository's immutable-tag procedure."
default:
return ""
}
}
func humanDiagnostic(err error) string {
var typed *releasekit.PublicationRecoveryError
if errors.As(err, &typed) && len(typed.MissingFields) > 0 {
return git.DisplayDiagnostic(fmt.Sprintf("%s (missing fields: %s): %s", typed.Kind, strings.Join(typed.MissingFields, ", "), err.Error()))
}
return git.DisplayError(err).Error()
}
func writeJSON(writer io.Writer, value any) error {
encoder := json.NewEncoder(writer)
return encoder.Encode(value)
}

View File

@@ -0,0 +1,487 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
releasekit "go.maximumdirect.net/releasekit"
"go.maximumdirect.net/releasekit/internal/publish"
)
func TestRecoveryEndpointSetupFailureDiagnostics(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture injects Git failures through a POSIX launcher")
}
for _, test := range []struct {
name, remote, kind, guidance string
exitCode int
}{
{"unnamed configuration", "", "transport_configuration", "Git configuration", 128},
{"named configuration", "origin", "transport_configuration", "Git configuration", 128},
{"rewrite", "", "endpoint_changed", "URL rewrite", 0},
{"tooling", "", "remote_observation", "Git diagnostic", 71},
} {
t.Run(test.name, func(t *testing.T) {
repo := newCLIRepository(t)
evidence := publish.Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3", Remote: test.remote}
path, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
if err != nil {
t.Fatal(err)
}
if test.kind == "transport_configuration" {
file, err := os.OpenFile(filepath.Join(repo, ".git", "config"), os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
t.Fatal(err)
}
if _, err := file.WriteString("\n[broken\n"); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
} else if test.kind == "endpoint_changed" {
cliGitRun(t, repo, "config", "url."+t.TempDir()+".insteadOf", repo)
}
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
marker := filepath.Join(t.TempDir(), "queried")
bin := t.TempDir()
failValidation := ""
if test.name == "tooling" {
failValidation = "[ \"$arg\" = check-ref-format ] && exit 71\n"
}
script := "#!/bin/sh\nquery=0\nget_url=0\nfor arg in \"$@\"; do\n" + failValidation + "[ \"$arg\" = ls-remote ] && query=1\n[ \"$arg\" = --get-url ] && get_url=1\ndone\nif [ \"$query\" = 1 ] && [ \"$get_url\" = 0 ]; then : > " + shellQuote(marker) + "; exit 72; fi\nexec " + shellQuote(realGit) + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
loaded, err := releasekit.LoadPublicationEvidence(path)
if err != nil {
t.Fatal(err)
}
result, err := releasekit.ReconcilePublication(t.Context(), loaded)
var typed *releasekit.PublicationRecoveryError
if !errors.As(err, &typed) || typed.Kind != test.kind || result.State != releasekit.PublicationUnknown {
t.Fatalf("setup classification = %#v, %v", result, err)
}
if test.exitCode != 0 {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != test.exitCode {
t.Fatalf("lost Git cause: %v", err)
}
}
for _, structured := range []bool{false, true} {
args := []string{"reconcile", "--record", path}
if structured {
args = append(args, "--json")
}
var stdout, stderr bytes.Buffer
if status := run(t.Context(), args, &stdout, &stderr); status != 3 {
t.Fatalf("setup status=%d: %s%s", status, &stdout, &stderr)
}
if structured {
var document reconciliationDocument
if err := json.Unmarshal(stdout.Bytes(), &document); err != nil {
t.Fatal(err)
}
if document.State != "unknown" || document.Diagnostic == nil || document.Diagnostic.Kind != test.kind {
t.Fatalf("setup JSON: %s", &stdout)
}
} else if !strings.Contains(stderr.String(), test.kind) || !strings.Contains(stderr.String(), test.guidance) {
t.Fatalf("setup guidance: %s", &stderr)
}
}
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("setup failure reached remote query: %v", err)
}
})
}
}
type failingRecoveryWriter struct{ err error }
func (writer failingRecoveryWriter) Write([]byte) (int, error) { return 0, writer.err }
func TestRecoveryCommandsReportJSONWriteFailure(t *testing.T) {
repo := newCLIRepository(t)
cliGitRun(t, repo, "tag", "-a", "v1.2.3", "-m", "Release")
store := t.TempDir()
path, err := publish.SaveEvidence(t.Context(), repo, store, publish.Evidence{
RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3",
CandidateCommit: cliGitOutput(t, repo, "rev-parse", "HEAD"),
TagObject: cliGitOutput(t, repo, "rev-parse", "v1.2.3"),
})
if err != nil {
t.Fatal(err)
}
failure := errors.New("output unavailable: https://user:audit-secret@example.invalid/output")
for _, args := range [][]string{{"reconcile", "--record", path, "--json"}, {"publication-attempts", "--record-dir", store, "--json"}} {
var normal, stderr bytes.Buffer
if status := run(t.Context(), args, &normal, &stderr); status != 0 {
t.Fatalf("normal output status=%d: %s", status, &stderr)
}
stderr.Reset()
if status := run(t.Context(), args, failingRecoveryWriter{failure}, &stderr); status != 1 {
t.Fatalf("failed output status=%d: %s", status, &stderr)
}
if !strings.Contains(stderr.String(), "output unavailable") || strings.Contains(stderr.String(), "audit-secret") {
t.Fatalf("output diagnostic: %s", &stderr)
}
if args[0] == "reconcile" && !strings.Contains(normal.String(), `"state":"published"`) {
t.Fatalf("observation changed: %s", &normal)
}
}
}
func TestDiscoveryReportsPartialAttemptsAndEnumerationFailure(t *testing.T) {
listed := releasekit.PublicationAttemptsResult{
RecordDir: "records",
Attempts: []releasekit.PublicationAttempt{{RecordPath: "good.json", Evidence: releasekit.PublicationEvidence{Tag: "v1.2.3"}}},
Diagnostics: []*releasekit.PublicationRecoveryError{{Kind: "record_format", RecordPath: "broken.json"}},
}
failure := &releasekit.PublicationRecoveryError{Kind: "storage_enumeration"}
for _, jsonOutput := range []bool{false, true} {
var stdout, stderr bytes.Buffer
if status := reportAttempts(listed, failure, jsonOutput, &stdout, &stderr); status != 1 {
t.Fatalf("partial discovery status=%d", status)
}
if jsonOutput {
var document attemptsDocument
if err := json.Unmarshal(stdout.Bytes(), &document); err != nil {
t.Fatal(err)
}
if len(document.Attempts) != 1 || document.Attempts[0].RecordPath != "good.json" || len(document.Diagnostics) != 1 || document.Diagnostics[0].RecordPath != "broken.json" || document.Diagnostic == nil || document.Diagnostic.Kind != "storage_enumeration" {
t.Fatalf("partial JSON lost data: %s", &stdout)
}
} else if !strings.Contains(stdout.String(), "good.json") || !strings.Contains(stderr.String(), "broken.json") || !strings.Contains(stderr.String(), "storage_enumeration") {
t.Fatalf("partial human output lost data: %s%s", &stdout, &stderr)
}
}
}
func TestRecoveryDisplaysRedactCredentialPathsAndMetadata(t *testing.T) {
secret := "audit-secret"
raw := "https://audit-user:" + secret + "@example.invalid/attempt.json"
for _, jsonOutput := range []bool{false, true} {
args := []string{"reconcile", "--record", raw}
if jsonOutput {
args = append(args, "--json")
}
var out, diagnostics bytes.Buffer
if status := run(t.Context(), args, &out, &diagnostics); status != 2 {
t.Fatalf("load status=%d", status)
}
if strings.Contains(out.String()+diagnostics.String(), secret) {
t.Fatalf("credential leaked: %s%s", &out, &diagnostics)
}
}
repo := newCLIRepository(t)
store := t.TempDir()
evidence := publish.Evidence{RepositoryDir: raw, ModuleDir: raw, ModulePath: raw, Version: raw, Tag: raw, CandidateCommit: raw, TagObject: raw, Remote: raw, RecordedAt: raw}
path, err := publish.SaveEvidence(t.Context(), repo, store, evidence)
if err != nil {
t.Fatal(err)
}
for _, command := range [][]string{{"reconcile", "--record", path}, {"publication-attempts", "--record-dir", store}} {
for _, jsonOutput := range []bool{false, true} {
args := append([]string{}, command...)
if jsonOutput {
args = append(args, "--json")
}
var out, diagnostics bytes.Buffer
run(t.Context(), args, &out, &diagnostics)
if strings.Contains(out.String()+diagnostics.String(), secret) {
t.Fatalf("metadata leaked: %s%s", &out, &diagnostics)
}
}
}
loaded, err := publish.LoadEvidence(path)
if err != nil || loaded != evidence {
t.Fatalf("private metadata changed: %#v, %v", loaded, err)
}
var out bytes.Buffer
reportPublicationRecord(t.Context(), releasekit.PublicationResult{RecordPath: raw, Phase: releasekit.PublicationPush}, fmt.Errorf("push failed"), &out)
if strings.Contains(out.String(), secret) {
t.Fatalf("handoff leaked credentials: %s", &out)
}
}
func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing.T) {
repository := newCLIRepository(t)
remote := filepath.Join(t.TempDir(), "bare remote.git")
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "tag", "-a", "v1.2.3", "-m", "first tag object")
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
store := filepath.Join(t.TempDir(), "records with ' punctuation")
evidence := publish.Evidence{
RepositoryDir: repository,
ModuleDir: repository,
ModulePath: "example.com/cli-candidate",
Version: "v1.2.3",
Tag: "v1.2.3",
CandidateCommit: commit,
TagObject: tagObject,
Endpoint: remote,
Remote: "origin",
RecordedAt: "2026-11-02T00:00:00Z",
}
record, err := publish.SaveEvidence(t.Context(), repository, store, evidence)
if err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "switch", "-c", "changed-head")
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "change current HEAD")
cliGitRun(t, repository, "tag", "-d", "v1.2.3")
if err := os.WriteFile(filepath.Join(repository, "uncommitted recovery check.txt"), []byte("dirty"), 0o600); err != nil {
t.Fatal(err)
}
otherDirectory := t.TempDir()
relativeRecord, err := filepath.Rel(otherDirectory, record)
if err != nil {
t.Fatal(err)
}
previousDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(otherDirectory); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.Chdir(previousDirectory); err != nil {
t.Errorf("restore working directory: %v", err)
}
})
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"reconcile", "--record", relativeRecord, "--json"}, &stdout, &stderr)
if status != 0 || stderr.Len() != 0 {
t.Fatalf("published status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
var published map[string]any
if err := json.Unmarshal(stdout.Bytes(), &published); err != nil {
t.Fatalf("decode published JSON: %v; output = %q", err, stdout.String())
}
if published["state"] != string("published") || published["record_path"] != relativeRecord {
t.Fatalf("published JSON = %#v", published)
}
if _, ok := published["diagnostic"]; ok {
t.Fatalf("successful reconciliation includes a diagnostic: %#v", published)
}
evidenceJSON, ok := published["evidence"].(map[string]any)
if !ok || evidenceJSON["candidate_commit"] != commit || evidenceJSON["tag_object"] != tagObject {
t.Fatalf("safe evidence = %#v", published["evidence"])
}
if evidenceJSON["module_path"] != evidence.ModulePath || evidenceJSON["candidateCommit"] != nil {
t.Fatalf("evidence does not use the versioned snake-case shape: %#v", evidenceJSON)
}
cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3")
cliGitRun(t, repository, "tag", "-f", "-a", "v1.2.3", "-m", "replacement tag object")
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
var conflictOut, conflictErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &conflictOut, &conflictErr)
if status != 1 || conflictErr.Len() != 0 {
t.Fatalf("conflict status = %d, stdout = %q, stderr = %q", status, conflictOut.String(), conflictErr.String())
}
var conflict map[string]any
if err := json.Unmarshal(conflictOut.Bytes(), &conflict); err != nil {
t.Fatal(err)
}
if conflict["state"] != string("conflict") || conflict["observed_tag_object"] == tagObject {
t.Fatalf("conflict JSON = %#v", conflict)
}
var humanOut, humanErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record}, &humanOut, &humanErr)
if status != 1 || !strings.Contains(humanOut.String(), "expected tag-object="+tagObject) || !strings.Contains(humanOut.String(), "observed tag-object=") || !strings.Contains(humanOut.String(), "Do not replace a conflicting remote tag") {
t.Fatalf("human conflict status = %d, stdout = %q, stderr = %q", status, humanOut.String(), humanErr.String())
}
partialEvidence := evidence
partialEvidence.TagObject = cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
partialEvidence.CandidateCommit = ""
partialRecord, err := publish.SaveEvidence(t.Context(), repository, store, partialEvidence)
if err != nil {
t.Fatal(err)
}
var unknownOut, unknownErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", partialRecord, "--json"}, &unknownOut, &unknownErr)
if status != 3 || unknownErr.Len() != 0 {
t.Fatalf("unknown status = %d, stdout = %q, stderr = %q", status, unknownOut.String(), unknownErr.String())
}
var unknown map[string]any
if err := json.Unmarshal(unknownOut.Bytes(), &unknown); err != nil {
t.Fatal(err)
}
unknownDiagnostic, ok := unknown["diagnostic"].(map[string]any)
if unknown["state"] != "unknown" || !ok || unknownDiagnostic["kind"] != "missing_evidence" {
t.Fatalf("unknown JSON = %#v", unknown)
}
var unknownHumanOut, unknownHumanErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", partialRecord}, &unknownHumanOut, &unknownHumanErr)
if status != 3 || !strings.Contains(unknownHumanOut.String(), "state=unknown") || !strings.Contains(unknownHumanErr.String(), "candidate_commit") {
t.Fatalf("human unknown status = %d, stdout = %q, stderr = %q", status, unknownHumanOut.String(), unknownHumanErr.String())
}
cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3")
var absentOut, absentErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &absentOut, &absentErr)
if status != 1 || absentErr.Len() != 0 {
t.Fatalf("absent status = %d, stdout = %q, stderr = %q", status, absentOut.String(), absentErr.String())
}
var absent map[string]any
if err := json.Unmarshal(absentOut.Bytes(), &absent); err != nil {
t.Fatal(err)
}
if absent["state"] != "not_published" {
t.Fatalf("absence JSON = %#v", absent)
}
if _, ok := absent["diagnostic"]; ok {
t.Fatalf("known absence includes an unresolved diagnostic: %#v", absent)
}
var absentHumanOut, absentHumanErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record}, &absentHumanOut, &absentHumanErr)
if status != 1 || !strings.Contains(absentHumanOut.String(), "state=not_published") || !strings.Contains(absentHumanOut.String(), "current absence only") {
t.Fatalf("human absence status = %d, stdout = %q, stderr = %q", status, absentHumanOut.String(), absentHumanErr.String())
}
brokenRecord := filepath.Join(store, "broken record's.json")
if err := os.WriteFile(brokenRecord, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
var brokenOut, brokenErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", brokenRecord, "--json"}, &brokenOut, &brokenErr)
if status != 2 || brokenErr.Len() != 0 {
t.Fatalf("record-format status = %d, stdout = %q, stderr = %q", status, brokenOut.String(), brokenErr.String())
}
var broken map[string]any
if err := json.Unmarshal(brokenOut.Bytes(), &broken); err != nil {
t.Fatal(err)
}
brokenDiagnostic, ok := broken["diagnostic"].(map[string]any)
if !ok || brokenDiagnostic["kind"] != "record_format" || broken["record_path"] != brokenRecord {
t.Fatalf("record-format JSON = %#v", broken)
}
for _, field := range []string{"evidence", "state", "observed_tag_object", "observed_commit"} {
if _, ok := broken[field]; ok {
t.Errorf("record-format JSON includes %q: %#v", field, broken)
}
}
}
func TestPublicationAttemptsCommandRedactsCredentialsAndReportsPartialDiscovery(t *testing.T) {
repository := newCLIRepository(t)
store := filepath.Join(t.TempDir(), "external records")
evidence := publish.Evidence{
RepositoryDir: repository,
ModulePath: "example.com/cli-candidate",
Version: "v1.2.3",
Tag: "v1.2.3",
Endpoint: "https://cli-user:cli-secret@example.test/repository.git",
Remote: "origin",
RecordedAt: "2026-11-02T00:00:00Z",
}
if _, err := publish.SaveEvidence(t.Context(), repository, store, evidence); err != nil {
t.Fatal(err)
}
brokenPath := filepath.Join(store, "damaged.json")
if err := os.WriteFile(brokenPath, []byte(`{"format_version":99,"evidence":{}}`), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"publication-attempts", "--record-dir", store, "--json"}, &stdout, &stderr)
if status != 1 || stderr.Len() != 0 {
t.Fatalf("partial discovery status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
output := stdout.String()
if strings.Contains(output, "cli-user") || strings.Contains(output, "cli-secret") || !strings.Contains(output, "https://redacted@example.test/repository.git") {
t.Fatalf("structured discovery exposed or failed to redact endpoint credentials: %q", output)
}
var listed map[string]any
if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil {
t.Fatal(err)
}
attempts, ok := listed["attempts"].([]any)
if !ok || len(attempts) != 1 {
t.Fatalf("attempts = %#v", listed["attempts"])
}
diagnostics, ok := listed["diagnostics"].([]any)
if !ok || len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v", listed["diagnostics"])
}
if listed["schema_version"] != float64(1) || listed["command"] != "publication-attempts" || listed["diagnostic"] != nil {
t.Fatalf("listing document = %#v", listed)
}
entry := attempts[0].(map[string]any)
if entry["record_path"] == "" {
t.Fatalf("attempt is missing record_path: %#v", entry)
}
entryEvidence := entry["evidence"].(map[string]any)
if entryEvidence["module_path"] != evidence.ModulePath || entryEvidence["recorded_at"] != evidence.RecordedAt {
t.Fatalf("attempt evidence = %#v", entryEvidence)
}
diagnostic := diagnostics[0].(map[string]any)
if diagnostic["kind"] != "record_format" || diagnostic["record_path"] != brokenPath {
t.Fatalf("record diagnostic = %#v", diagnostic)
}
emptyStore := filepath.Join(t.TempDir(), "empty store")
if err := os.Mkdir(emptyStore, 0o700); err != nil {
t.Fatal(err)
}
var emptyOut, emptyErr bytes.Buffer
status = run(context.Background(), []string{"publication-attempts", "--record-dir", emptyStore, "--json"}, &emptyOut, &emptyErr)
if status != 0 || emptyErr.Len() != 0 {
t.Fatalf("empty discovery status = %d, stdout = %q, stderr = %q", status, emptyOut.String(), emptyErr.String())
}
var empty map[string]any
if err := json.Unmarshal(emptyOut.Bytes(), &empty); err != nil {
t.Fatal(err)
}
if len(empty["attempts"].([]any)) != 0 || len(empty["diagnostics"].([]any)) != 0 || empty["diagnostic"] != nil {
t.Fatalf("empty listing document = %#v", empty)
}
var failedOut, failedErr bytes.Buffer
status = run(context.Background(), []string{"publication-attempts", "--record-dir", filepath.Join(emptyStore, "missing"), "--json"}, &failedOut, &failedErr)
if status != 1 || failedErr.Len() != 0 {
t.Fatalf("enumeration failure status = %d, stdout = %q, stderr = %q", status, failedOut.String(), failedErr.String())
}
var failed map[string]any
if err := json.Unmarshal(failedOut.Bytes(), &failed); err != nil {
t.Fatal(err)
}
failedDiagnostic, ok := failed["diagnostic"].(map[string]any)
if !ok || failedDiagnostic["kind"] != "storage_enumeration" {
t.Fatalf("enumeration failure JSON = %#v", failed)
}
}
func TestRecoveryArgumentErrorsUseExistingUsagePath(t *testing.T) {
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"reconcile", "--json"}, &stdout, &stderr)
if status != 2 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "reconcile requires --record FILE") {
t.Fatalf("argument failure status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
if strings.Contains(stdout.String(), "schema_version") || strings.Contains(stderr.String(), fmt.Sprint("\"command\"")) {
t.Fatalf("argument failure unexpectedly used structured output: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}

View File

@@ -236,6 +236,26 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
assertToolOnlyChecksums(t, sumLog) assertToolOnlyChecksums(t, sumLog)
}) })
t.Run("vendored tool dependency", func(t *testing.T) {
goDir, goLog := installGoBootstrapShim(t)
repository := newCLIRepository(t)
const module = "go.maximumdirect.net/releasekit"
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n\ntool "+module+"/cmd/releasekit\n")
env := templateEnvironment(repository, "", goDir, goLog)
useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy))
runWoodpeckerBlock(t, "go mod tidy\ngo mod vendor", repository, env)
cliGitRun(t, repository, "add", "go.mod", "go.sum", "vendor")
cliGitRun(t, repository, "commit", "-m", "vendor the selected release tool")
env["CI_COMMIT_SHA"] = pushTaggedCLIRepository(t, repository)
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum"))
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "tool-version=v0.2.197 helper=tool-only dependency") {
t.Fatalf("vendored tool was not selected: %q", output)
}
assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore)
})
t.Run("versioned replacement selects its declared source", func(t *testing.T) { t.Run("versioned replacement selects its declared source", func(t *testing.T) {
goDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
repository := newCLIRepository(t) repository := newCLIRepository(t)
@@ -400,7 +420,7 @@ func installGoBootstrapShim(t *testing.T) (string, string) {
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG" printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
"$RELEASEKIT_REAL_GO" "$@" "$RELEASEKIT_REAL_GO" "$@"
status=$? status=$?
if [ "$status" -eq 0 ] && [ "$1" = "build" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then if [ "$status" -eq 0 ] && [ "$1" = "tool" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then
modfile= modfile=
for argument in "$@"; do for argument in "$@"; do
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
@@ -433,20 +453,12 @@ case "$1" in
list) list)
printf '%s v0.2.0\n' "$RELEASEKIT_MODULE" printf '%s v0.2.0\n' "$RELEASEKIT_MODULE"
;; ;;
build) mod)
output= ;;
previous= tool)
for argument in "$@"; do shift 3
if [ "$previous" = "-o" ]; then output=$argument; break; fi printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG"
previous=$argument exit "${RELEASEKIT_TOOL_EXIT:-0}"
done
[ -n "$output" ] || { echo 'go build omitted output path' >&2; exit 90; }
cat > "$output" <<'TOOL'
#!/bin/sh
printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG"
exit "${RELEASEKIT_TOOL_EXIT:-0}"
TOOL
chmod +x "$output"
;; ;;
*) *)
echo "unexpected Go command: $1" >&2 echo "unexpected Go command: $1" >&2
@@ -532,7 +544,7 @@ func writeReleaseKitModuleProxy(t *testing.T) string {
toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n" toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n"
for _, version := range []string{"v0.2.197", "v0.2.198"} { for _, version := range []string{"v0.2.197", "v0.2.198"} {
files := map[string]string{ files := map[string]string{
"cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; tooldep \"example.com/releasekit-helper\")\n\nfunc main() {\n\tfmt.Println(\"tool-version=" + version + " helper=\" + tooldep.Version)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n", "cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; \"strconv\"; tooldep \"example.com/releasekit-helper\")\n\nfunc main() {\n\tfmt.Println(\"tool-version=" + version + " helper=\" + tooldep.Version)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n\tstatus, _ := strconv.Atoi(os.Getenv(\"RELEASEKIT_TOOL_EXIT\"))\n\tos.Exit(status)\n}\n",
} }
writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files) writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files)
} }
@@ -633,16 +645,16 @@ func assertGoSelection(t *testing.T, repository, goLog string) {
t.Helper() t.Helper()
log := readWoodpeckerFixtureFile(t, goLog) log := readWoodpeckerFixtureFile(t, goLog)
list := "list -mod=mod -modfile=" list := "list -mod=mod -modfile="
build := "build -mod=mod -modfile=" tool := "tool -modfile="
module := "go.maximumdirect.net/releasekit" module := "go.maximumdirect.net/releasekit"
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) { if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) {
t.Fatalf("Go module selection command is missing required settings: %q", log) t.Fatalf("Go module selection command is missing required settings: %q", log)
} }
if !strings.Contains(log, build) || !strings.Contains(log, module+"/cmd/releasekit") || strings.Contains(log, "@v0.2.0") { if !strings.Contains(log, tool) || !strings.Contains(log, " releasekit ci-deliver ") || strings.Contains(log, "@v0.2.0") {
t.Fatalf("Go build did not use the selected module without a version suffix: %q", log) t.Fatalf("Go tool did not run the module-selected ReleaseKit: %q", log)
} }
if strings.Index(log, list) > strings.Index(log, build) { if strings.Index(log, list) > strings.Index(log, tool) {
t.Fatalf("Go build ran before module selection: %q", log) t.Fatalf("Go tool ran before module selection: %q", log)
} }
var modfile string var modfile string
for _, field := range strings.Fields(log) { for _, field := range strings.Fields(log) {

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

@@ -1,6 +1,6 @@
# Command line # Command line
ReleaseKit currently provides six commands. The exit statuses below belong ReleaseKit currently provides eight commands. The exit statuses below belong
to the native executable. `go run` examples are convenient for source execution, to the native executable. `go run` examples are convenient for source execution,
but the Go driver maps a nonzero child exit status to 1. Use the built executable but the Go driver maps a nonzero child exit status to 1. Use the built executable
or `./scripts/release` for publication when distinct statuses are needed. or `./scripts/release` for publication when distinct statuses are needed.
@@ -75,7 +75,7 @@ Unsupported argument sequences print usage to standard error.
Publish a source tag: Publish a source tag:
```sh ```sh
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] [--record-dir DIR] VERSION
``` ```
`publish` reads the same product, remote, validation, and additional-check `publish` reads the same product, remote, validation, and additional-check
@@ -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
@@ -137,13 +138,76 @@ mismatches fail during preflight before Go validation or tag creation. Git URL
rewrites are resolved first, and the push URL is used when fetch and push URLs rewrites are resolved first, and the push URL is used when fetch and push URLs
differ. `check` ignores this field. differ. `check` ignores this field.
The command automatically saves available publication evidence in the repository's
private attempt store. Use `--record-dir DIR` to select another directory; a
relative override is resolved from the source repository root. Standard error
reports the absolute saved record path or a recording warning separately from
the publication result. Recording failure does not change publication status.
When publication fails and a record was saved, the CLI also prints a copyable
`reconcile --record` command for that exact record. The command selects the
consumer module's registered Go tool when available. API-only consumers receive
a Go-tool invocation with durable private module files; see
[recovery storage](consumers/publish.md#retain-and-reconcile-publication-evidence).
Consumers without a module-selected ReleaseKit dependency use the running
executable's absolute path. A module-preparation failure reports a warning and
uses a persistent native executable when available. Recognized temporary Go-build
executables provide no native fallback after their driver exits. Command
preparation has its own bounded context after a failed publication, so publication
cancellation permits preparing the later invocation. Each entry point preserves
the recovery exit status.
If the selected entry point becomes unavailable, use another supported ReleaseKit
CLI with the saved record.
The command returns status 0 for confirmed publication, 1 for a known The command returns status 0 for confirmed publication, 1 for a known
publication failure or conflict, 2 for usage, profile, or invalid-version publication failure or conflict, 2 for usage, profile, or invalid-version
errors, and 3 when the push outcome remains unknown. Unknown status must be errors, and 3 when the push outcome remains unknown. Unknown status must be
resolved against the authoritative remote before retrying. The resolved with the explicit-record recovery operation below before retrying. The
[maintainer procedure](release.md) selects the repository profile and gives [maintainer procedure](release.md) selects the repository profile and gives
the source-release workflow. the source-release workflow.
## Recover a saved publication attempt or discover local attempts
```sh
releasekit reconcile --record FILE [--json]
releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]
```
`reconcile` requires one explicitly selected record and reads a relative `FILE`
from the invoking directory. It observes the endpoint and tag stored in that
record; it does not load a profile, inspect the current candidate or local tag,
run checks, or change repository state. `publication-attempts` lists records in
the selected store without querying remotes or choosing an attempt. An omitted
`--repo` means the current directory. Its relative `--record-dir` is resolved
from the selected repository root; an absolute directory can be listed outside
a checkout. The selected directory must already exist. Discovery reports usable
records and per-record diagnostics together when some records cannot be read.
By default, both operations print human-readable output. `--json` writes one
structured document to standard output after valid arguments, including for
reconciliation and discovery failures. Recovery does not run candidate checks
or make hosted delivery requests. It reads the record's captured endpoint, while
using current supported transport settings for its recorded remote name.
| Recovery command result | Status |
| --- | --- |
| Reconciliation observes the intended publication | 0 |
| Reconciliation observes absence or conflict | 1 |
| Reconciliation cannot read or decode the selected record, or has an invocation error | 2 |
| Reconciliation remains unknown, including missing evidence or cancellation | 3 |
| Discovery completes without record diagnostics, including an existing empty store | 0 |
| Discovery is incomplete or fails, including cancellation | 1 |
| Discovery argument or usage error | 2 |
| Either command cannot write its JSON output | 1 |
A JSON write failure reports a local output diagnostic on standard error. This
status describes reporting failure and leaves the observed publication state
unchanged.
The CLI JSON schema and safe evidence fields are documented in the
[publication record format](integrations/publication-records.md). Recovery
guidance for storage, older evidence, and retry decisions is in the
[maintainer procedure](release.md#resolve-publication-failures).
Build selected application targets: Build selected application targets:
```sh ```sh
@@ -208,8 +272,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 +307,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
@@ -272,9 +338,9 @@ status 2. Final delivery uses the same statuses as `deliver`: 0 for complete,
state, including when cancellation prevents reconciliation. state, including when cancellation prevents reconciliation.
The maintained module-based tag workflow verifies and checks out the event tag The maintained module-based tag workflow verifies and checks out the event tag
before resolving the consumer module. It builds the ReleaseKit version already before resolving the consumer module. It runs `go tool releasekit` for the version
selected by that tagged `go.mod`, using temporary alternate module files outside already selected by that tagged `go.mod`, using temporary alternate module files
the source checkout. This tool bootstrap is separate from `ci-deliver`'s outside the source checkout. This tool bootstrap is separate from `ci-deliver`'s
application dependency preparation for binary profiles. ReleaseKit's own application dependency preparation for binary profiles. ReleaseKit's own
source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared
ReleaseKit module dependency can use the separately installed-tool command. ReleaseKit module dependency can use the separately installed-tool command.

View File

@@ -34,8 +34,21 @@ Go minimum. See the
[Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool). [Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool).
Run `go tool releasekit` from within the consumer module to use its selected Run `go tool releasekit` from within the consumer module to use its selected
version. Follow the [CLI contract](../cli.md) for commands and exit statuses, version. Go builds and caches the executable and preserves its exit status.
and the [configuration reference](../config.md) for the release profile. A launcher invoked from that module can forward commands directly:
```sh
#!/bin/sh
exec go tool releasekit "$@"
```
For a launcher callable from other directories, first change to its consumer
module directory. Follow the [CLI contract](../cli.md) for commands and exit
statuses, and the [configuration reference](../config.md) for the release profile.
For consumers using vendoring, register the CLI before running `go mod vendor`
so the vendor tree includes its dependencies. `go tool` follows Go's normal
vendor selection.
## Go API consumers ## Go API consumers
@@ -89,9 +102,10 @@ Go module identity. The Go consumer minimum remains 1.26.0.
## CI adoption ## CI adoption
The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows) The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows)
supports both tool and ordinary package dependencies. It builds ReleaseKit from supports both tool and ordinary package dependencies. It runs `go tool releasekit`
the tagged consumer's module selection, so the committed module files also with temporary module metadata derived from the tagged consumer's module
govern CI delivery and retries. Registering the CLI as a tool is sufficient for selection, so the committed module files also govern CI delivery and retries.
Registering the CLI as a tool is sufficient for
consumers that use ReleaseKit only for maintainer commands. consumers that use ReleaseKit only for maintainer commands.
Upgrade the module-selected tool and workflow together. For a tag created before Upgrade the module-selected tool and workflow together. For a tag created before

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
@@ -289,11 +293,13 @@ identify an annotated tag object or a commit. The template requires the resolved
commits to match, checks out that commit detached, and confirms the source tree commits to match, checks out that commit detached, and confirms the source tree
is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary
directory outside the checkout. `go list -m` confirms that the module selects directory outside the checkout. `go list -m` confirms that the module selects
ReleaseKit; Go uses ReleaseKit. The template adds its CLI tool declaration to that temporary copy
an alternate `-modfile` to prepare dependencies and build the selected and prepares tool dependencies with `go list -deps`. It then uses
`cmd/releasekit` package without changing the tagged module files. The template `go tool -modfile=... releasekit` to build, cache, and run the selected tool with
invokes that executable with `CI_WORKSPACE`, the resolved event commit, and `CI_WORKSPACE`, the resolved event commit, and `CI_COMMIT_TAG`. The tagged module
`CI_COMMIT_TAG`. A cleanup trap removes the temporary metadata and executable. files stay unchanged, including for consumers with an ordinary package
dependency. A cleanup trap removes the temporary metadata; Go manages the
cached executable. The command preserves the tool's exit status.
Tool bootstrap and application artifact preparation are separate. The bootstrap Tool bootstrap and application artifact preparation are separate. The bootstrap
resolves and builds ReleaseKit from the tagged consumer's module configuration. resolves and builds ReleaseKit from the tagged consumer's module configuration.

View File

@@ -114,9 +114,81 @@ 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.
## Retain and reconcile publication evidence
`Publish` automatically attempts to save one private record for each invocation.
The default store is under the repository's Git common directory, so linked
worktrees share it. `ReleaseRequest.PublicationRecordDir` can override the
directory; relative overrides resolve from the source repository root. The CLI
equivalent is `publish --record-dir DIR`. ReleaseKit creates its owned storage
directories privately and creates record files with owner-only permissions;
existing directory permissions are left as they are. The caller owns storage
retention and removal.
The CLI prepares a recovery entry point when a failed publication has a saved
record. Registered-tool consumers use their selected module's Go tool directly.
Command preparation uses a separate bounded context after publication failure,
including cancellation. A module-preparation warning can accompany a persistent
native executable fallback; the saved record and publication result retain their
original meaning.
For API-only consumers, ReleaseKit retains private bootstrap module and checksum files
under `<Git common directory>/releasekit/publication-recovery/`, with private
directory and file permissions. These files add the CLI tool declaration and
prepare its module graph without changing consumer source. The bootstrap has
its own module root, so tool resolution is independent of consumer vendoring;
local replacements retain their original source locations. These files survive
a launcher's temporary module-file cleanup. Registered-tool commands run from
the selected module; API-only commands run from the private bootstrap root.
Retain the source checkout and private recovery files while the entry point is
needed. Remove the private recovery directories when
their recovery value expires. Another supported CLI can read the saved record
independently of these launch files.
The saved endpoint is retained exactly for later observation and may include
credentials. Treat records, `PublicationEvidence`, and `PublicationResult` as
sensitive. Store overrides in a location with suitable access controls, avoid
printing raw endpoint values, and remove records when their recovery value has
expired. Diagnostic errors and CLI output apply credential redaction; the
private record and typed evidence preserve the raw endpoint.
Recording failure is reported in `PublicationResult.RecordError`, separately
from `Publish`'s return error and publication state. A successful push remains
successful if storage fails, but the saved handoff is unavailable. Callers can
still pass `result.Evidence` directly to `ReconcilePublication` when it contains
the required identity. For example:
```go
func observe(ctx context.Context, result releasekit.PublicationResult) (releasekit.PublicationReconciliationResult, error) {
return releasekit.ReconcilePublication(ctx, result.Evidence)
}
```
The evidence is populated as publication identity becomes available, including
partial evidence on some earlier failures. An abrupt process termination before
the save point can leave no record; removed storage also removes the handoff.
Records from older attempts may lack the tag object, endpoint, or other identity
needed for a conclusive observation.
Use `LoadPublicationEvidence(path)` to read one explicit record,
`ListPublicationAttempts(ctx, request)` to discover readable records and
per-record diagnostics in a local directory, or `ReconcilePublication(ctx,
evidence)` to observe a selected endpoint. Listing does not query remotes, and
neither listing nor reconciliation changes saved records. Recovery does not
infer the attempt from current HEAD, local tags, module files, or profiles.
`ReconcilePublication` returns a nil error for a completed `published`,
`not_published`, or `conflict` observation; unresolved `unknown` results include
a typed error that retains the underlying cause. See the
[GoDoc](../../recovery.go) for the exported fields and error kinds, the
[CLI guide](../cli.md) for command statuses, and the
[record format guide](../integrations/publication-records.md) for supported
stored and structured formats.

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

@@ -0,0 +1,123 @@
# Publication records and recovery output
This guide describes the supported private publication-record envelope and the
versioned JSON documents emitted by the recovery CLI. The
[publication consumer guide](../consumers/publish.md) owns storage placement,
permissions, retention, and sensitive-data handling. The
[CLI reference](../cli.md) owns command behavior, path selection, and exit
statuses; the exported Go contract is documented in [GoDoc](../../recovery.go).
## Private attempt record
Each saved attempt is one complete JSON document with this versioned envelope:
```json
{
"format_version": 1,
"evidence": {
"repository_dir": "/work/project",
"module_dir": "/work/project",
"module_path": "example.com/project",
"version": "v1.2.3",
"tag": "v1.2.3",
"candidate_commit": "<full Git object ID>",
"tag_object": "<full Git object ID>",
"endpoint": "ssh://git.example/owner/project.git",
"remote": "origin",
"recorded_at": "2026-11-02T12:00:00Z"
}
}
```
The format version is an integer. Evidence fields may be absent when publication
stopped before they were captured; loading preserves that partial evidence and
does not fill fields from the current checkout. Additional fields in a
supported version are ignored. A missing or unsupported version, malformed or
multiple JSON documents, or fields with invalid structural types are reported
as record-format errors. The codec supports versions 1 and 2; no migration from
an unsupported version is performed.
Ordinary UTF-8 evidence uses version 1. Evidence containing other pathname or
endpoint bytes uses version 2 with the same `evidence` object and an additional
`evidence_bytes` object. Each entry in `evidence_bytes` uses an evidence field
name and a standard base64 string containing its exact bytes. That entry
supplies the field's value when loading; the writer omits its lossy string form
from `evidence`. Invalid known-field types or base64 encodings produce a
record-format error with the available decoding cause. Unknown field names are
ignored. This representation applies
to every evidence field and preserves the same identity as typed Go evidence.
Older CLIs that support only version 1 report an unsupported version for these
byte-containing records; use a CLI supporting version 2 to load them.
The `endpoint` value is stored exactly as captured and may contain credentials.
Private records are sensitive. Do not copy them into public logs or source
control. For their location, permissions, and lifecycle, see the
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence).
## Recovery CLI JSON
After valid command arguments, `--json` emits one JSON document to standard
output for both successful and failed recovery operations. Each document has
`schema_version: 1` and a `command` name. These are CLI presentation documents,
not private attempt records or a Go serialization of public structs.
`reconcile` includes `record_path`, safe `evidence`, and `state` when a record
was read. Available `observed_tag_object` and `observed_commit` fields are
included when present. A `diagnostic` is included for unresolved observation
or record loading failure. Loading failure omits evidence, state, and observed
IDs.
`publication-attempts` includes `record_dir`, an `attempts` array with each
`record_path` and safe `evidence`, and a `diagnostics` array. A top-level
`diagnostic` describes enumeration or cancellation failure. Both arrays are
present when empty so consumers can decode the same shape for empty stores.
For example, an unresolved reconciliation can have this shape:
```json
{
"schema_version": 1,
"command": "reconcile",
"record_path": "/private/attempt-abc.json",
"evidence": {
"repository_dir": "/work/project",
"module_path": "example.com/project",
"version": "v1.2.3",
"tag": "v1.2.3",
"candidate_commit": "<full Git object ID>",
"tag_object": "<full Git object ID>",
"endpoint": "ssh://git.example/owner/project.git",
"remote": "origin"
},
"state": "unknown",
"diagnostic": {
"kind": "remote_observation",
"message": "<credential-safe diagnostic>"
}
}
```
A discovery document always contains both arrays, including for an empty store:
```json
{
"schema_version": 1,
"command": "publication-attempts",
"record_dir": "/private/publication-attempts",
"attempts": [],
"diagnostics": []
}
```
CLI evidence uses the private record's snake-case field names. Endpoint strings
in CLI output are credential-redacted; the private record and typed
`PublicationEvidence` retain the exact endpoint. A diagnostic contains `kind`
and safe `message`, with `record_path` and `missing_fields` when applicable.
Raw Go errors and private record contents are not emitted as JSON fields.
Consumers should select attempts by their returned record paths and evidence.
The listing order is deterministic by path, not a recommendation to choose the
first or newest attempt. Unknown fields may be added in later CLI schema
versions; reject unsupported `schema_version` values rather than interpreting
them as version 1. See the [CLI reference](../cli.md#recover-a-saved-publication-attempt-or-discover-local-attempts)
for invocation and status behavior.

View File

@@ -8,21 +8,37 @@ Standalone and delivery builds create their own owners. The package uses
`internal/process` for cancellation, the working directory, output, and wrapped `internal/process` for cancellation, the working directory, output, and wrapped
errors. errors.
For CLI publication recovery, the package selects a registered module tool or
prepares a durable private module root for an API-only consumer. Go's module
graph query includes transitive dependencies; preparation retains module
requirements, versioned replacements, and checksums. Local replacements resolve
against their original module directory before relocation. The original main
module remains a local replacement, including when releasing ReleaseKit itself.
The private root provides tool resolution independently of consumer vendoring.
The CLI formats the later invocation using a separate bounded context after
publication failure. It falls back to a persistent native executable when module
selection is unavailable or preparation fails, and excludes recognized temporary
Go-work executables from that fallback. The
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence)
owns recovery-file placement and retention.
## Command output ownership ## Command output ownership
`Tool.Run` executes a status-only command. It forwards combined stdout and `Tool.Run` executes a status-only command. It forwards combined stdout and
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 +51,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

@@ -11,12 +11,14 @@ two Go facades and a command-line interface:
- `buildinfo` is the small runtime facade. `Version` reports the injected - `buildinfo` is the small runtime facade. `Version` reports the injected
application version, the main-module version when available, or `dev`. application version, the main-module version when available, or `dev`.
- `cmd/releasekit` is the executable interface. Its `version`, `check`, - `cmd/releasekit` is the executable interface. Its `version`, `check`,
`publish`, `build`, `deliver`, and `ci-deliver` commands adapt profiles and `publish`, `reconcile`, `publication-attempts`, `build`, `deliver`, and
command output to the supported library operations. `ci-deliver` commands adapt profiles and command output to the supported
library operations.
See the [CLI contract](../cli.md), [configuration reference](../config.md), See the [CLI contract](../cli.md), [configuration reference](../config.md),
[candidate check guide](../consumers/check.md), [candidate check guide](../consumers/check.md),
[source publication guide](../consumers/publish.md), [source publication guide](../consumers/publish.md),
[publication record format](../integrations/publication-records.md),
[build guide](../consumers/build.md), [build guide](../consumers/build.md),
[delivery guide](../consumers/delivery.md), and [delivery guide](../consumers/delivery.md), and
[runtime version guide](../consumers/runtime-version.md) for task-oriented use. [runtime version guide](../consumers/runtime-version.md) for task-oriented use.
@@ -49,7 +51,7 @@ reports these implemented packages:
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
| `internal/releaseidentity` | Committed-object and working-tree module selection, declaration reading, and semantic-version-to-tag derivation. | | `internal/releaseidentity` | Committed-object and working-tree module selection, declaration reading, and semantic-version-to-tag derivation. |
| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | | `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, pre-push evidence recording, saved-attempt discovery, later observation, and recovery state. |
| `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. | | `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. |
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
@@ -75,6 +77,18 @@ it does not consult neighboring files or traverse submodule checkouts.
See the [shared validation guide](validation.md) for checker, Go command, and See the [shared validation guide](validation.md) for checker, Go command, and
candidate-file integration mechanics. candidate-file integration mechanics.
Publication records and recovery follow a separate path from candidate
validation. `internal/publish` captures and saves attempt evidence before the
tag push, lists local records, and observes explicitly selected evidence through
`internal/git`; the root facade owns the reusable Go evidence and result types.
The CLI's `reconcile` and `publication-attempts` commands load, present, and
select records without loading a profile or choosing an attempt automatically.
These operations do not rerun candidate checks or hosted delivery and do not
change local or remote state. The [publication consumer guide](../consumers/publish.md)
owns storage and API use; the [record format guide](../integrations/publication-records.md)
owns the private and CLI JSON formats; the [CLI contract](../cli.md) owns command
semantics and statuses.
The root facade selects the backend explicitly and passes a configured The root facade selects the backend explicitly and passes a configured
`internal/forge.Backend` into `internal/delivery`. Delivery performs cheap `internal/forge.Backend` into `internal/delivery`. Delivery performs cheap
request and source checks, then read-only backend preflight before managed request and source checks, then read-only backend preflight before managed
@@ -94,14 +108,15 @@ 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` |
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
| Saved attempt recording, discovery, explicit later reconciliation, and read-only recovery | `releasekit`, `internal/publish`, `internal/git`, `cmd/releasekit` | `TestLostPublicationOutputCanBeRecoveredFromExplicitRecords`, `TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts`, `TestRecoveryFacadeLoadsPartialEvidenceAndPreservesCause`, `TestRecoveryFacadeErrorUnwrapsCancellation`, `TestListPublicationAttemptsReadsExplicitExternalStore` |
| Cross-builds, version smoke checks, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` | | Cross-builds, version smoke checks, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` |
| Explicit destination selection, standalone and automatic preflight, and delivery ordering | `releasekit`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliveryForgeSelectionIsExplicit`, `TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout`, `TestPreflightDeliveryPreservesPublicFailureKindsAndCauses`, `TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing`, `TestDeliverUnavailablePreflightStopsBeforeManagedBuild`, `TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation`, `TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne` | | Explicit destination selection, standalone and automatic preflight, and delivery ordering | `releasekit`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliveryForgeSelectionIsExplicit`, `TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout`, `TestPreflightDeliveryPreservesPublicFailureKindsAndCauses`, `TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing`, `TestDeliverUnavailablePreflightStopsBeforeManagedBuild`, `TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation`, `TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne` |
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` | | Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |

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

@@ -124,45 +124,65 @@ the complete checked-in release note. Report its result separately from the
completed source release. For failed or uncertain delivery, follow the recovery completed source release. For failed or uncertain delivery, follow the recovery
instructions below using the same immutable version. instructions below using the same immutable version.
## Resolve failures ## Resolve publication failures
A failed push triggers a URL-resolution check against the captured endpoint, Each publication invocation attempts to save its captured identity before the
then at most one exact authoritative tag query. If the endpoint no longer tag push. The CLI reports the saved record path separately from publication
resolves to itself, recovery reports `unknown` without querying the rewritten success or failure and, after a push or immediate-reconciliation error, prints a
destination. An exact tag-object match confirms publication even when Git copyable command that selects that exact record. Run that command, or use the
reported an error. A proven absent tag is `not_published`; a different object source-built executable prepared above, to make a separate later observation:
is a conflict; an unreadable query is `unknown`. The command retains its local
tag and reports the state, phase,
candidate commit, tag object, and captured publication endpoint when available.
Do not retry an unknown result until you resolve the authoritative ref at that
exact endpoint. Copy the reported endpoint value into the variable below using
normal shell quoting; retain the version and tag object from the same result.
The command checks that Git URL rewriting still resolves this value to itself;
the manual check below applies the same rule before querying:
```sh ```sh
releasekit_endpoint='COPY_CAPTURED_ENDPOINT' ./.releasekit/bin/releasekit reconcile --record '/absolute/path/to/attempt-record.json'
releasekit_version='vX.Y.Z'
test "$(git ls-remote --get-url -- "$releasekit_endpoint")" = "$releasekit_endpoint" &&
git ls-remote --refs -- "$releasekit_endpoint" "refs/tags/$releasekit_version"
``` ```
A failed URL-equality check or remote query leaves the outcome unresolved. The saved evidence fixes the original endpoint and tag identity. Later recovery
Correct transport configuration before continuing. The reported endpoint may uses current supported transport settings for the recorded remote name, but it
be different from `origin`'s fetch URL or its current push URL; querying either does not substitute the current remote URL, inspect the local tag or current
of those does not establish absence at the captured endpoint. HEAD, load a profile, rerun checks, or change repository state. A completed
observation reports `published`, `not_published`, or `conflict`; an incomplete
observation reports `unknown` with diagnostic context. Resolve an unknown result
by correcting the identified endpoint rewrite, transport configuration, or
missing evidence. For incomplete saved identity, select another record with the
required fields or use complete typed evidence through the Go API.
Compare the returned object ID with the tag object reported by the command. If command output was lost, list local attempts and explicitly select the
An exact match confirms publication. If the ref is absent, the version was not matching record by its module, full tag, and saved identity:
published; only then may you deliberately remove the unpublished local tag
with `git tag -d vX.Y.Z` before correcting the candidate or retrying. Do not ```sh
move, overwrite, or delete a tag that exists remotely. If a different object ./.releasekit/bin/releasekit publication-attempts --repo "$PWD"
uses the version, choose a new version after resolving the conflict. ./.releasekit/bin/releasekit reconcile --record '/absolute/path/from-the-list/attempt-record.json'
```
Discovery only reads the local store. It neither queries remotes nor chooses an
attempt automatically. If the original recovery entry point is unavailable,
select another supported ReleaseKit CLI and pass the same record path explicitly.
An older record may lack the endpoint, tag-object ID, candidate commit, or
another field required for a conclusive result.
A later observation that the tag is absent describes the remote at that time;
it does not prove the version was never published. Never reuse a version known
to have been published, and preserve published tags and contents as immutable.
Keep removal of an unpublished local tag, candidate correction, and retry as
explicit operator decisions after reviewing the record and authoritative
remote state. A conflicting object requires resolving the conflict and selecting
an unused version when appropriate; do not move, overwrite, or delete a
published tag.
If record storage fails, ReleaseKit reports the recording warning separately;
the warning does not change the actual publication outcome. The saved handoff is
then unavailable. A Go caller that retained `PublicationResult.Evidence` can
still pass that typed evidence directly to `ReconcilePublication`. Process
termination before the record is written, removed storage, and older incomplete
records can leave no sufficient saved evidence for later recovery.
Later reconciliation adds no successful-push read-back, polling, CI, consumer
installation, or hosted-delivery gate. Hosted page delivery remains separate.
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 reviewing the selected record and authoritative state
a concurrent publisher may already have published the version. The prepared before deciding whether to remove it. The prepared
Woodpecker workflow builds the CLI from the tagged checkout, then uses Woodpecker workflow builds the CLI from the tagged checkout, then uses
`ci-deliver` for page-only delivery. To activate it after configuring the `ci-deliver` for page-only delivery. To activate it after configuring the
destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml` destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml`

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)

54
docs/releases/v0.8.0.md Normal file
View File

@@ -0,0 +1,54 @@
# ReleaseKit v0.8.0
## Summary
ReleaseKit adds read-only recovery for source-publication attempts. It attempts
to retain evidence before a tag push, lets operators discover and explicitly
select a saved attempt, and observes the original destination without repeating
candidate validation or changing tags. Source publication and hosted delivery
continue to have separate outcomes.
## Publication recovery
- `Publish` returns reusable `PublicationEvidence` and attempts to save a
private record. The result reports the saved path or a recording error
separately from the publication outcome. Go callers can load or list records
and reconcile retained evidence through the root facade.
- The CLI adds `publication-attempts` for local discovery and `reconcile` for
an explicitly selected record. Both support human-readable and structured
output. `publish --record-dir` selects an alternate record directory, and
failed publication can print a recovery command for the saved attempt.
- Recovery uses the captured endpoint and full tag with current supported Git
transport settings. It reports publication, current absence, conflict, or an
unresolved observation. Current absence does not prove that a version was
never published; published versions and their contents remain immutable.
- Records preserve exact endpoint and path bytes. Ordinary UTF-8 evidence uses
record format 1; format 2 preserves other bytes. Recovery diagnostics redact
HTTP(S) URL userinfo, while private records and typed evidence can contain
sensitive endpoints. Operators control record retention and removal.
## Consumer tools and compatibility
The module path remains `go.maximumdirect.net/releasekit`, and the Go consumer
minimum remains 1.26.0. Existing publication calls and release profiles remain
supported. Publication now attempts to write a private record by default; a
storage failure is reported separately and does not change the publication
result. Applications handling results should inspect `RecordError` when a saved
recovery handoff matters.
Consumers with the CLI registered as a Go tool in their module can run it with
`go tool releasekit`.
The maintained multi-module launcher and Woodpecker examples use the selected
module's tool version. Register the CLI before generating a vendor tree when
using a vendored tool. Older recovery CLIs that only support record format 1
cannot load format 2 records; use a CLI that supports format 2.
See the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/publish.md),
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/cli.md),
[publication record formats](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/integrations/publication-records.md),
and [consumer adoption guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/adoption.md)
for usage and recovery details.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.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

@@ -72,11 +72,14 @@ Then select a module and semantic version with the shared launcher:
The launcher copies the selected module's `go.mod` and `go.sum` to temporary The launcher copies the selected module's `go.mod` and `go.sum` to temporary
alternate module files outside the checkout, resolves ReleaseKit from that alternate module files outside the checkout, resolves ReleaseKit from that
module's local dependency selection, and builds the tool there. It invokes module's local dependency selection, adds the CLI tool declaration to that
`publish` with the repository profile and `--expected-module` so the library temporary copy, and prepares its dependencies. It uses `go tool releasekit`
can verify that the profile agrees with the selected table row. The temporary with the alternate module file to invoke `publish` with the repository profile
files and tool are removed on exit. As with any `publish` invocation, commit the and `--expected-module` so the library can verify that the profile agrees with
selected module's release note and prepare its repository before publishing. the selected table row. Go builds and caches the executable and preserves its
exit status; the launcher removes the temporary metadata on exit. As with any
`publish` invocation, commit the selected module's release note and prepare its
repository before publishing.
If an integration depends on a sibling release, publish the dependency first, If an integration depends on a sibling release, publish the dependency first,
then publish the integration with the version that consumes it. For example, then publish the integration with the version that consumes it. For example,
@@ -86,8 +89,9 @@ This declaration does not infer dependency order or rerun the standard suite.
The maintained [Woodpecker example](woodpecker.yml) uses the same map and The maintained [Woodpecker example](woodpecker.yml) uses the same map and
selector for root, `ckconfig`, and major-version tags. It verifies and detaches selector for root, `ckconfig`, and major-version tags. It verifies and detaches
the literal event tag before selecting the module, copies that module's metadata the literal event tag before selecting the module, copies that module's metadata
outside the checkout, and builds the ReleaseKit version selected there. It then outside the checkout, and runs the ReleaseKit version selected there through
uses `ci-deliver --prepared` with the mapped profile and expected physical module. `go tool releasekit`. It then uses `ci-deliver --prepared` with the mapped profile
and expected physical module.
The `core-v2` profile demonstrates binary delivery; the other profiles are The `core-v2` profile demonstrates binary delivery; the other profiles are
page-only. Before enabling the workflow, configure each selected profile's page-only. Before enabling the workflow, configure each selected profile's
`delivery` object with `"forge": "gitea"`, its `base_url`, owner, and repository, `delivery` object with `"forge": "gitea"`, its `base_url`, owner, and repository,

View File

@@ -37,12 +37,11 @@ releasekit_module=go.maximumdirect.net/releasekit
export GOWORK=off export GOWORK=off
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module" go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module"
go build -mod=mod -modfile="$bootstrap_dir/releasekit.mod" \ go mod edit -modfile="$bootstrap_dir/releasekit.mod" -tool="$releasekit_module/cmd/releasekit"
-o "$bootstrap_dir/releasekit" "$releasekit_module/cmd/releasekit" go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -deps "$releasekit_module/cmd/releasekit" > /dev/null
go tool -modfile="$bootstrap_dir/releasekit.mod" releasekit publish \
--repo "$repository_dir" \
--config "$profile_path" \
--expected-module "$module_dir" \
"$version"
) )
"$bootstrap_dir/releasekit" publish \
--repo "$repository_dir" \
--config "$profile_path" \
--expected-module "$module_dir" \
"$version"

View File

@@ -40,14 +40,12 @@ steps:
if [ "$${module_dir}" != . ]; then if [ "$${module_dir}" != . ]; then
module_path="$${CI_WORKSPACE}/$${module_dir}" module_path="$${CI_WORKSPACE}/$${module_dir}"
fi fi
cp "$${module_path}/go.mod" "$${bootstrap_dir}/go.mod" cp "$${module_path}/go.mod" "$${bootstrap_dir}/releasekit.mod"
if [ -f "$${module_path}/go.sum" ]; then if [ -f "$${module_path}/go.sum" ]; then
cp "$${module_path}/go.sum" "$${bootstrap_dir}/go.sum" cp "$${module_path}/go.sum" "$${bootstrap_dir}/releasekit.sum"
else else
: > "$${bootstrap_dir}/go.sum" : > "$${bootstrap_dir}/releasekit.sum"
fi fi
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
export GOWORK=off export GOWORK=off
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
@@ -55,13 +53,13 @@ steps:
( (
cd "$${module_path}" cd "$${module_path}"
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}" go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \ go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit"
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit" go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null
go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \
--repo "$${CI_WORKSPACE}" \
--config "$${profile_path}" \
--expected-module "$${module_dir}" \
--prepared \
--commit "$${expected_commit}" \
"$${CI_COMMIT_TAG}"
) )
"$${bootstrap_dir}/releasekit" ci-deliver \
--repo "$${CI_WORKSPACE}" \
--config "$${profile_path}" \
--expected-module "$${module_dir}" \
--prepared \
--commit "$${expected_commit}" \
"$${CI_COMMIT_TAG}"

View File

@@ -32,22 +32,20 @@ steps:
bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX") bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX")
trap 'rm -rf "$${bootstrap_dir}"' EXIT trap 'rm -rf "$${bootstrap_dir}"' EXIT
cp go.mod "$${bootstrap_dir}/go.mod" cp go.mod "$${bootstrap_dir}/releasekit.mod"
if [ -f go.sum ]; then if [ -f go.sum ]; then
cp go.sum "$${bootstrap_dir}/go.sum" cp go.sum "$${bootstrap_dir}/releasekit.sum"
else else
: > "$${bootstrap_dir}/go.sum" : > "$${bootstrap_dir}/releasekit.sum"
fi fi
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
export GOWORK=off export GOWORK=off
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
releasekit_module=go.maximumdirect.net/releasekit releasekit_module=go.maximumdirect.net/releasekit
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}" go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \ go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit"
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit" go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null
"$${bootstrap_dir}/releasekit" ci-deliver \ go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \
--repo "$${CI_WORKSPACE}" \ --repo "$${CI_WORKSPACE}" \
--prepared \ --prepared \
--commit "$${expected_commit}" \ --commit "$${expected_commit}" \

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
}
break
}
if attempts == 99 {
readErr = io.ErrNoProgress
break
}
}
} }
return hex.EncodeToString(hasher.Sum(nil)), nil 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
} }
@@ -128,8 +131,9 @@ func (reader *BlobReader) startLocked() error {
Args: []string{"git", "cat-file", "--batch"}, Args: []string{"git", "cat-file", "--batch"},
Dir: reader.repositoryDir, Dir: reader.repositoryDir,
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`)

43
internal/git/display.go Normal file
View File

@@ -0,0 +1,43 @@
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)
}
// DisplayDiagnostic redacts recognized HTTP credentials in a display string.
func DisplayDiagnostic(message string) string { return displayDiagnostic(message) }
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

@@ -13,13 +13,14 @@ import (
) )
var ( var (
ErrInvalidRemote = errors.New("invalid Git remote") ErrInvalidRemote = errors.New("invalid Git remote")
ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs") ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs")
ErrEndpointChanged = errors.New("Git publication endpoint changed") ErrEndpointChanged = errors.New("Git publication endpoint changed")
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch") ErrTransportConfiguration = errors.New("Git transport configuration is unreadable")
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit") ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote") ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable") ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote")
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable")
) )
// Endpoint identifies the single effective publication destination captured // Endpoint identifies the single effective publication destination captured
@@ -60,14 +61,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 {
@@ -84,15 +85,56 @@ func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, e
return endpoint, nil return endpoint, nil
} }
// ObserveEndpoint creates a read-only command-scoped endpoint from previously
// captured publication evidence. The recorded URL is used for both directions;
// current transport settings are copied only when the original remote exists.
func ObserveEndpoint(ctx context.Context, repositoryDir, endpointURL, remoteName string) (Endpoint, error) {
if endpointURL == "" {
return Endpoint{}, fmt.Errorf("empty captured endpoint")
}
scopedName, err := newScopedName()
if err != nil {
return Endpoint{}, fmt.Errorf("create command-scoped remote name: %w", err)
}
endpoint := Endpoint{RemoteName: remoteName, PushURL: endpointURL, ScopedName: scopedName}
if remoteName != "" {
config, configErr := transportConfig(ctx, repositoryDir, remoteName, scopedName)
if configErr != nil {
return Endpoint{}, configErr
}
endpoint.transport = config
}
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
return Endpoint{}, err
}
return endpoint, nil
}
// EqualObjectID compares supported full object IDs without regard to hex case.
func EqualObjectID(left, right string) bool {
return validObjectID(left) && validObjectID(right) && strings.EqualFold(left, right)
}
// ValidObjectID reports whether value is a full supported Git object ID.
func ValidObjectID(value string) bool { return validObjectID(value) }
// DisplayError exposes credential-safe rendering for shared recovery errors.
func DisplayError(err error) error {
if err == nil {
return nil
}
return displayError{cause: err}
}
// Recheck confirms both the selected remote's effective push destination and // Recheck confirms both the selected remote's effective push destination and
// the scoped remote's effective read and push URLs still identify this endpoint. // the scoped remote's effective read and push URLs still identify this endpoint.
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 +151,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 +161,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:
@@ -161,11 +203,11 @@ func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, ta
func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error { func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error {
readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName) readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName)
if err != nil { if err != nil {
return fmt.Errorf("resolve scoped read URL: %w", err) return fmt.Errorf("%w: resolve scoped read URL: %w", ErrTransportConfiguration, err)
} }
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 +239,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("%w: read transport setting remote.%s.%s: %w", ErrTransportConfiguration, 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 +251,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 +276,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
} }
@@ -303,15 +347,33 @@ func finalizeRef(ref RemoteRef) RemoteRef {
// CheckTagRef validates a complete tag name using Git ref rules. // CheckTagRef validates a complete tag name using Git ref rules.
func CheckTagRef(ctx context.Context, repositoryDir, tag string) error { func CheckTagRef(ctx context.Context, repositoryDir, tag string) error {
if tag == "" { valid, err := ValidateTagRef(ctx, repositoryDir, tag)
if err != nil {
return err
}
if !valid {
return fmt.Errorf("invalid Git tag name %q", tag) return fmt.Errorf("invalid Git tag name %q", tag)
} }
if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil {
return fmt.Errorf("invalid Git tag name %q: %w", tag, err)
}
return nil return nil
} }
// ValidateTagRef distinguishes a rejected ref from failures to run Git or
// access the selected repository.
func ValidateTagRef(ctx context.Context, repositoryDir, tag string) (bool, error) {
if tag == "" {
return false, nil
}
_, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag)
if err == nil {
return true, nil
}
var exitErr interface{ ExitCode() int }
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("check Git tag name: %w", err)
}
func validObjectID(object string) bool { func validObjectID(object string) bool {
if len(object) != 40 && len(object) != 64 { if len(object) != 40 && len(object) != 64 {
return false return false

View File

@@ -42,6 +42,46 @@ func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) {
} }
} }
func TestObserveEndpointUsesCapturedURLAndCurrentNamedTransportSettings(t *testing.T) {
repo := newRemoteFixture(t)
gitRun(t, repo.work, "remote", "remove", "origin")
gitRun(t, repo.work, "config", "remote.renamed.receivepack", "git-receive-pack")
endpoint, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "renamed")
if err != nil {
t.Fatal(err)
}
if endpoint.PushURL != repo.bare || len(endpoint.transport) != 1 || endpoint.transport[0] != "remote."+endpoint.ScopedName+".receivepack=git-receive-pack" {
t.Fatalf("observed endpoint = %#v", endpoint)
}
ref, err := endpoint.QueryTag(context.Background(), repo.work, "v1.2.3")
if err != nil || ref.State != RefAbsent {
t.Fatalf("QueryTag() = %#v, %v", ref, err)
}
}
func TestObserveEndpointDoesNotSelectOriginWithoutAName(t *testing.T) {
repo := newRemoteFixture(t)
gitRun(t, repo.work, "config", "remote.origin.receivepack", "custom-receive-pack")
endpoint, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "")
if err != nil {
t.Fatal(err)
}
if len(endpoint.transport) != 0 {
t.Fatalf("transport settings = %v; unnamed endpoint must not select origin", endpoint.transport)
}
}
func TestObserveEndpointRejectsCapturedURLRewriting(t *testing.T) {
repo := newRemoteFixture(t)
replacement := filepath.Join(t.TempDir(), "replacement.git")
gitRun(t, repo.work, "init", "--bare", replacement)
gitRun(t, repo.work, "config", "url."+replacement+".insteadOf", repo.bare)
_, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "")
if !errors.Is(err, ErrEndpointChanged) {
t.Fatalf("ObserveEndpoint() error = %v, want ErrEndpointChanged", err)
}
}
func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) { func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) {
fixture := newRemoteFixture(t) fixture := newRemoteFixture(t)
endpoint, err := Resolve(context.Background(), fixture.work, "origin") endpoint, err := Resolve(context.Background(), fixture.work, "origin")

View File

@@ -56,6 +56,45 @@ func normalizeRepositoryRoot(root string) (string, error) {
return resolved, nil return resolved, nil
} }
// MetadataDirectories returns the checkout's private and shared Git directories.
func MetadataDirectories(ctx context.Context, directory string) ([]string, error) {
var directories []string
for _, flag := range []string{"--absolute-git-dir", "--git-common-dir"} {
output, err := run(ctx, directory, nil, "rev-parse", "--path-format=absolute", flag)
if err != nil {
return nil, fmt.Errorf("resolve Git metadata directory: %w", err)
}
resolved, err := normalizeRepositoryRoot(output)
if err != nil {
return nil, err
}
directories = append(directories, resolved)
}
return directories, nil
}
// CommonDirectory resolves the shared Git metadata directory.
func CommonDirectory(ctx context.Context, directory string) (string, error) {
output, err := run(ctx, directory, nil, "rev-parse", "--path-format=absolute", "--git-common-dir")
if err != nil {
return "", fmt.Errorf("resolve shared Git metadata directory: %w", err)
}
return normalizeRepositoryRoot(output)
}
// IsIgnored checks whether Git excludes the selected worktree path.
func IsIgnored(ctx context.Context, directory, path string) (bool, error) {
_, err := run(ctx, directory, nil, "check-ignore", "-q", "--", path)
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
return false, err
}
// DiscoverRepositoryRoot finds a containing checkout or confirms standalone use. // DiscoverRepositoryRoot finds a containing checkout or confirms standalone use.
func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) { func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) {
if directory == "" { if directory == "" {
@@ -64,7 +103,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 +238,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 +254,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 +456,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,36 +20,45 @@ 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 {
// Verify capture and forwarding for this workload outside the measurement. name string
var forwarded bytes.Buffer mode captureMode
verification := &combinedWriter{output: &forwarded} }{{"full", captureFull}, {"status", captureStatus}} {
for offset := 0; offset < len(input); offset += testCase.chunkSize { b.Run(testCase.name+"/"+mode.name, func(b *testing.B) {
chunk := input[offset:min(offset+testCase.chunkSize, len(input))] // Verify capture and forwarding for this workload outside the measurement.
if written, err := verification.Write(chunk); err != nil || written != len(chunk) { var forwarded bytes.Buffer
b.Fatalf("verify output write = %d, err %v; want %d bytes", written, err, len(chunk)) verification := &combinedWriter{output: &forwarded, mode: mode.mode}
}
}
if !bytes.Equal(verification.take(), input) || !bytes.Equal(forwarded.Bytes(), input) {
b.Fatal("captured and forwarded output must equal the complete input")
}
b.ReportAllocs()
b.SetBytes(int64(len(input)))
b.ResetTimer()
b.ReportMetric(float64(len(input)), "bytes/op")
for i := 0; i < b.N; i++ {
writer := &combinedWriter{output: io.Discard}
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 := verification.Write(chunk); err != nil || written != len(chunk) {
b.Fatalf("write captured output = %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))
} }
} }
captured := writer.take() want := input
if len(captured) != len(input) { if mode.mode == captureStatus && len(want) > statusDiagnosticLimit {
b.Fatalf("captured output = %d bytes, want %d", len(captured), len(input)) 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.SetBytes(int64(len(input)))
b.ResetTimer()
b.ReportMetric(float64(len(input)), "bytes/op")
for i := 0; i < b.N; i++ {
writer := &combinedWriter{output: io.Discard, mode: mode.mode}
for offset := 0; offset < len(input); offset += testCase.chunkSize {
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
if written, err := writer.Write(chunk); err != nil || written != len(chunk) {
b.Fatalf("write captured output = %d, err %v; want %d bytes", written, err, len(chunk))
}
}
captured := writer.take()
if len(captured) != len(want) {
b.Fatalf("captured output = %d bytes, want %d", len(captured), len(want))
}
}
})
}
} }
} }

154
internal/gotool/recovery.go Normal file
View File

@@ -0,0 +1,154 @@
package gotool
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"golang.org/x/mod/modfile"
"go.maximumdirect.net/releasekit/internal/process"
)
const releaseKitModule = "go.maximumdirect.net/releasekit"
const releaseKitCommand = releaseKitModule + "/cmd/releasekit"
// TemporaryExecutable recognizes executables in Go's temporary build layout,
// <work parent>/go-build<nonce>/b<action>/exe/<name>. Those paths disappear when
// the Go driver finishes and cannot provide a later native-command fallback.
func TemporaryExecutable(path string) bool {
exeDir := filepath.Dir(path)
if filepath.Base(exeDir) != "exe" {
return false
}
actionDir := filepath.Dir(exeDir)
action, ok := strings.CutPrefix(filepath.Base(actionDir), "b")
if !ok || action == "" {
return false
}
if _, err := strconv.ParseUint(action, 10, 64); err != nil {
return false
}
nonce, ok := strings.CutPrefix(filepath.Base(filepath.Dir(actionDir)), "go-build")
if !ok || nonce == "" {
return false
}
_, err := strconv.ParseUint(nonce, 10, 64)
return err == nil
}
// RecoveryTool returns module-selected Go arguments for a later invocation.
// An empty result means this module does not select ReleaseKit. API-only modules
// receive a private module root that outlives launcher-owned bootstraps.
func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, error) {
path := filepath.Join(moduleDir, "go.mod")
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
module, err := modfile.Parse(path, data, nil)
if err != nil {
return nil, err
}
for _, tool := range module.Tool {
if tool.Path == releaseKitCommand {
return []string{"-C", moduleDir, "tool", "-modfile=", releaseKitCommand}, nil
}
}
// Ask Go about the selected module graph, including transitive dependencies.
// This reads real module metadata, independently of a launcher's -modfile.
var selection bytes.Buffer
err = process.Run(ctx, process.Invocation{
Args: []string{"go", "list", "-e", "-json", "-mod=readonly", "-modfile=", "-m", releaseKitModule},
Dir: moduleDir, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"},
Stdout: &selection, Stderr: io.Discard,
})
if err != nil {
return nil, fmt.Errorf("identify recovery tool selection: %w", err)
}
var selected struct {
Main bool
Version string
Error *struct{ Err string }
}
if err := json.Unmarshal(selection.Bytes(), &selected); err != nil {
return nil, err
}
if !selected.Main && selected.Version == "" {
// A source-built CLI also supports consumers without a module dependency.
return nil, nil
}
if selected.Error != nil {
return nil, fmt.Errorf("identify recovery tool selection: %s", selected.Error.Err)
}
if err := module.AddTool(releaseKitCommand); err != nil {
return nil, err
}
// Relocating the root avoids the consumer's vendor tree. Preserve local
// replacements relative to the directory that originally owned go.mod.
for _, replacement := range module.Replace {
if replacement.New.Version == "" && !filepath.IsAbs(replacement.New.Path) {
if err := module.AddReplace(replacement.Old.Path, replacement.Old.Version, filepath.Join(moduleDir, replacement.New.Path), ""); err != nil {
return nil, err
}
}
}
// The original main module's source remains local if a dependency refers
// to it. This also permits recovery when releasing ReleaseKit itself.
if err := module.AddReplace(module.Module.Mod.Path, "", moduleDir, ""); err != nil {
return nil, err
}
if selected.Main {
if err := module.AddRequire(releaseKitModule, "v0.0.0"); err != nil {
return nil, err
}
}
if err := module.AddModuleStmt("releasekit.local/recovery"); err != nil {
return nil, err
}
data, err = module.Format()
if err != nil {
return nil, err
}
sums, err := os.ReadFile(filepath.Join(moduleDir, "go.sum"))
if err != nil && !os.IsNotExist(err) {
return nil, err
}
if err := os.MkdirAll(recordDir, 0700); err != nil {
return nil, err
}
directory, err := os.MkdirTemp(recordDir, "recovery-")
if err != nil {
return nil, fmt.Errorf("create durable recovery module directory: %w", err)
}
complete := false
defer func() {
if !complete {
_ = os.RemoveAll(directory)
}
}()
if err := os.WriteFile(filepath.Join(directory, "go.mod"), data, 0600); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(directory, "go.sum"), sums, 0600); err != nil {
return nil, err
}
// Prepare the private graph and sums before the later tool invocation.
captured := &combinedWriter{mode: captureStatus}
err = process.Run(ctx, process.Invocation{
Args: []string{"go", "list", "-mod=mod", "-modfile=", "-deps", releaseKitCommand},
Dir: directory, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"},
Stdout: captured, Stderr: captured,
})
if err != nil {
return nil, fmt.Errorf("prepare recovery tool: %w: %s", err, diagnosticText(captured.take(), captured.omitted()))
}
complete = true
return []string{"-C", directory, "tool", "-modfile=", releaseKitCommand}, nil
}

View File

@@ -0,0 +1,22 @@
package gotool
import (
"path/filepath"
"testing"
)
func TestRecoveryFallbackExecutableLifetime(t *testing.T) {
for _, test := range []struct {
path string
temporary bool
}{
{filepath.Join("tmp", "go-build123456", "b001", "exe", "releasekit"), true},
{filepath.Join("cache", "go-build", "hash-d", "releasekit"), false},
{filepath.Join("tmp", "tools", "releasekit"), false},
{filepath.Join("workspace", "go-build-project", "b001", "exe", "releasekit"), false},
} {
if got := TemporaryExecutable(test.path); got != test.temporary {
t.Errorf("temporary executable %q = %t, want %t", test.path, got, test.temporary)
}
}
}

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

@@ -0,0 +1,112 @@
package publish
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
type Attempt struct {
RecordPath string
Evidence Evidence
}
type AttemptsResult struct {
RecordDir string
Attempts []Attempt
Diagnostics []*RecoveryError
}
func ListAttempts(ctx context.Context, repositoryDir, override string) (AttemptsResult, error) {
return listAttempts(ctx, repositoryDir, override, recordPaths)
}
func listAttempts(ctx context.Context, repositoryDir, override string, enumerate func(context.Context, string) ([]string, error)) (AttemptsResult, error) {
if repositoryDir == "" {
repositoryDir = "."
}
result := AttemptsResult{Attempts: []Attempt{}, Diagnostics: []*RecoveryError{}}
if err := ctx.Err(); err != nil {
return result, recoveryFailure(ctx, "storage_enumeration", err)
}
var directory string
{
resolved, err := recordDirectoryFor(ctx, repositoryDir, override)
if err != nil {
return result, recoveryFailure(ctx, "storage_enumeration", err)
}
directory = resolved
}
result.RecordDir = directory
if err := ctx.Err(); err != nil {
return result, &RecoveryError{Kind: "canceled", cause: err}
}
paths, enumerationErr := enumerate(ctx, directory)
sort.Strings(paths)
for _, path := range paths {
if err := ctx.Err(); err != nil {
return result, &RecoveryError{Kind: "canceled", cause: err}
}
absolute, err := filepath.Abs(path)
if err != nil {
result.Diagnostics = append(result.Diagnostics, &RecoveryError{Kind: "record_read", RecordPath: path, cause: err})
continue
}
evidence, err := LoadEvidence(absolute)
if err != nil {
var recordError *RecoveryError
if errors.As(err, &recordError) {
result.Diagnostics = append(result.Diagnostics, recordError)
}
continue
}
result.Attempts = append(result.Attempts, Attempt{RecordPath: absolute, Evidence: evidence})
}
if enumerationErr != nil {
return result, recoveryFailure(ctx, "storage_enumeration", fmt.Errorf("enumerate records in %q: %w", directory, enumerationErr))
}
return result, nil
}
// recordPaths filters unsorted batches, retaining only selected record names.
func recordPaths(ctx context.Context, directory string) ([]string, error) {
file, err := os.Open(directory)
if err != nil {
return nil, err
}
defer file.Close()
return readRecordPaths(ctx, directory, file.ReadDir)
}
func readRecordPaths(ctx context.Context, directory string, read func(int) ([]os.DirEntry, error)) ([]string, error) {
var paths []string
for {
if err := ctx.Err(); err != nil {
return paths, err
}
entries, err := read(128)
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
paths = append(paths, filepath.Join(directory, entry.Name()))
}
}
if errors.Is(err, io.EOF) {
return paths, nil
}
if err != nil {
return paths, err
}
}
}
func recordDirectoryFor(ctx context.Context, repositoryDir, override string) (string, error) {
if filepath.IsAbs(override) {
return filepath.Clean(override), nil
}
directory, _, err := resolveRecordDir(ctx, repositoryDir, override)
return directory, err
}

View File

@@ -0,0 +1,147 @@
package publish
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
)
func TestRecoveryAlreadyCanceledDiscovery(t *testing.T) {
for _, override := range []string{"", "relative"} {
ctx, cancel := context.WithCancel(t.Context())
cancel()
result, err := ListAttempts(ctx, t.TempDir(), override)
requireCanceledRecovery(t, err)
if len(result.Attempts) != 0 {
t.Fatalf("unexpected attempts: %#v", result)
}
}
}
func requireCanceledRecovery(t *testing.T, err error) {
t.Helper()
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation = %v; want canceled kind and cause", err)
}
}
func TestRecoveryCancelsActiveGitOperations(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture uses a POSIX Git launcher")
}
tests := []struct {
name, command string
run func(context.Context, string) error
}{
{"default discovery root", "--show-toplevel", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, ""); return err }},
{"relative discovery root", "--show-toplevel", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, "records"); return err }},
{"default discovery metadata", "--git-common-dir", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, ""); return err }},
{"record metadata", "--git-common-dir", func(ctx context.Context, repo string) error {
_, err := SaveEvidence(ctx, repo, "", Evidence{})
return err
}},
{"record ignore guard", "check-ignore", func(ctx context.Context, repo string) error {
_, err := SaveEvidence(ctx, repo, "records", Evidence{})
return err
}},
{"publication metadata", "--git-common-dir", func(ctx context.Context, repo string) error {
result, err := Publish(ctx, Request{RepositoryDir: repo, Product: "Sample", Version: "v1.2.3"})
if !errors.Is(result.RecordError, context.Canceled) {
return fmt.Errorf("record cancellation lost: %v", result.RecordError)
}
return err
}},
{"tag validation", "check-ref-format", func(ctx context.Context, repo string) error {
_, err := Reconcile(ctx, Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3"})
return err
}},
{"tag query", "ls-remote", func(ctx context.Context, repo string) error {
_, err := Reconcile(ctx, Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3"})
return err
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := recordRepo(t)
if test.name == "publication metadata" {
if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module example.com/sample\n\ngo 1.26.0\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(repo, "docs", "releases"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, "docs", "releases", "v1.2.3.md"), []byte("# Sample v1.2.3\n\nChanges.\n"), 0600); err != nil {
t.Fatal(err)
}
recordGit(t, repo, "checkout", "-B", "main")
recordGit(t, repo, "add", ".")
recordGit(t, repo, "commit", "-qm", "Release candidate")
}
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
ready := filepath.Join(t.TempDir(), "ready")
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" }
script := "#!/bin/sh\nfor arg in \"$@\"; do\n if [ \"$arg\" = " + quote(test.command) + " ]; then\n echo $$ > " + quote(ready) + "\n exec sleep 600\n fi\ndone\nexec " + quote(realGit) + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
done := make(chan error, 1)
go func() { done <- test.run(ctx, repo) }()
// Synchronize cancellation with the intercepted operation, rather than timing its start.
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
timeout := time.NewTimer(10 * time.Second)
defer timeout.Stop()
var pid int
waiting:
for {
select {
case err := <-done:
t.Fatalf("operation finished before interception: %v", err)
case <-timeout.C:
t.Fatal("Git operation was never intercepted")
case <-ticker.C:
data, err := os.ReadFile(ready)
if err == nil {
pid, err = strconv.Atoi(strings.TrimSpace(string(data)))
if err == nil {
break waiting
}
}
}
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("lost context cause: %v", err)
}
if strings.Contains(test.name, "discovery") || test.command == "check-ref-format" || test.command == "ls-remote" {
requireCanceledRecovery(t, err)
}
case <-time.After(5 * time.Second):
process, _ := os.FindProcess(pid)
if process != nil {
_ = process.Kill()
}
<-done
t.Fatal("cancellation did not stop the Git operation")
}
})
}
}

View File

@@ -48,6 +48,7 @@ type Request struct {
Version string Version string
Remote string Remote string
ExpectedPushURL string ExpectedPushURL string
RecordDir string
Validation validation.Settings Validation validation.Settings
AdditionalChecks func(context.Context, candidate.Candidate) error AdditionalChecks func(context.Context, candidate.Candidate) error
AfterValidate func(context.Context, candidate.Candidate, *gotool.Owner) error AfterValidate func(context.Context, candidate.Candidate, *gotool.Owner) error
@@ -65,6 +66,9 @@ type Result struct {
Phase Phase Phase Phase
TagObject string TagObject string
State State State State
Evidence Evidence
RecordPath string
RecordError error
} }
// Publish captures a candidate, resolves and checks the optional expected push // Publish captures a candidate, resolves and checks the optional expected push
@@ -74,8 +78,22 @@ type Result struct {
// A failed push receives one bounded authoritative tag query. Exact // A failed push receives one bounded authoritative tag query. Exact
// object equality confirms publication; absence and conflict are classified, // object equality confirms publication; absence and conflict are classified,
// while an unreadable result remains Unknown and preserves the push error. // while an unreadable result remains Unknown and preserves the push error.
func Publish(ctx context.Context, request Request) (Result, error) { func Publish(ctx context.Context, request Request) (result Result, resultErr error) {
result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted} result = Result{Version: request.Version, Phase: Preflight, State: NotAttempted}
var recordDir, repositoryRoot string
initialized, recorded := false, false
defer func() {
if !initialized || recorded || result.RecordError != nil {
return
}
recorded = true
path, err := saveEvidenceInDir(ctx, result.RepositoryDir, repositoryRoot, request.RecordDir, recordDir, result.Evidence)
if err != nil {
result.RecordError = err
return
}
result.RecordPath = path
}()
remoteName := request.Remote remoteName := request.Remote
if remoteName == "" { if remoteName == "" {
remoteName = "origin" remoteName = "origin"
@@ -89,14 +107,26 @@ func Publish(ctx context.Context, request Request) (Result, error) {
AdditionalChecks: request.AdditionalChecks, AdditionalChecks: request.AdditionalChecks,
Output: request.Output, Output: request.Output,
} }
captured, err := candidate.Capture(ctx, checks) captured, captureErr := candidate.Capture(ctx, checks)
result.RepositoryDir = captured.RepositoryDir result.RepositoryDir = captured.RepositoryDir
result.ModuleDir = captured.ModuleDir result.ModuleDir = captured.ModuleDir
result.ModulePath = captured.ModulePath result.ModulePath = captured.ModulePath
result.Tag = captured.Tag result.Tag = captured.Tag
result.CandidateCommit = captured.Commit result.CandidateCommit = captured.Commit
if err != nil { result.Evidence = Evidence{RepositoryDir: captured.RepositoryDir, ModuleDir: captured.ModuleDir, ModulePath: captured.ModulePath, Version: captured.Version, Tag: captured.Tag, CandidateCommit: captured.Commit, Remote: remoteName}
return result, fmt.Errorf("capture release candidate: %w", err) if captured.RepositoryDir != "" {
initialized = true
result.Evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano)
var storageErr error
recordDir, repositoryRoot, storageErr = resolveRecordDir(ctx, captured.RepositoryDir, request.RecordDir)
if storageErr != nil {
result.RecordError = &RecoveryError{Kind: "record_write", cause: storageErr}
recordDir = ""
recorded = true
}
}
if captureErr != nil {
return result, fmt.Errorf("capture release candidate: %w", captureErr)
} }
endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName) endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName)
@@ -104,21 +134,22 @@ func Publish(ctx context.Context, request Request) (Result, error) {
return result, fmt.Errorf("resolve publication endpoint: %w", err) return result, fmt.Errorf("resolve publication endpoint: %w", err)
} }
result.Endpoint = endpoint.PushURL result.Endpoint = endpoint.PushURL
result.Evidence.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)
} }
if err := candidate.Recheck(ctx, captured, ""); err != nil {
return result, fmt.Errorf("pre-validation local guard: candidate worktree and index must remain clean: %w", err)
}
if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Tag, captured.Commit); err != nil { if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Tag, captured.Commit); err != nil {
if isRemoteConflict(err) { if isRemoteConflict(err) {
result.State = Conflict result.State = Conflict
} }
return result, fmt.Errorf("pre-validation remote guard: %w", err) return result, fmt.Errorf("pre-validation remote guard: %w", err)
} }
if err := candidate.Recheck(ctx, captured, ""); err != nil {
return result, fmt.Errorf("pre-validation local guard: %w", err)
}
result.Phase = Validation result.Phase = Validation
checks.ToolOwner = gotool.NewOwner(captured.ModuleDir) checks.ToolOwner = gotool.NewOwner(captured.ModuleDir)
@@ -147,10 +178,11 @@ 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
result.Evidence.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)
} }
@@ -166,6 +198,15 @@ func Publish(ctx context.Context, request Request) (Result, error) {
result.Phase = Push result.Phase = Push
result.State = Unknown result.State = Unknown
if !recorded {
recorded = true
path, recordErr := saveEvidenceInDir(ctx, result.RepositoryDir, repositoryRoot, request.RecordDir, recordDir, result.Evidence)
if recordErr != nil {
result.RecordError = recordErr
} else {
result.RecordPath = path
}
}
if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Tag, tagObject); err != nil { if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Tag, tagObject); err != nil {
return reconcilePush(endpoint, captured, tagObject, result, err) return reconcilePush(endpoint, captured, tagObject, result, err)
} }
@@ -179,28 +220,29 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout) recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
defer cancel() defer cancel()
remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Tag) remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Tag)
if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict { observation, classifyErr := classify(Evidence{RepositoryDir: captured.RepositoryDir, Tag: captured.Tag, CandidateCommit: captured.Commit, TagObject: tagObject, Endpoint: endpoint.PushURL}, remoteTag)
if queryErr != nil || classifyErr != nil {
result.State = Unknown result.State = Unknown
if queryErr == nil { if queryErr == nil {
if remoteTag.State == git.RefConflict { if remoteTag.State == git.RefConflict {
queryErr = errors.New("authoritative tag query returned inconsistent exact ref records") queryErr = errors.New("authoritative tag query returned inconsistent exact ref records")
} else { } else {
queryErr = git.ErrRemoteStateUnreadable queryErr = classifyErr
} }
} }
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 observation.State == NotPublished {
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 observation.State == Published {
result.Phase = Complete result.Phase = Complete
result.State = Published result.State = Published
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

@@ -0,0 +1,153 @@
package publish
import (
"context"
"errors"
"go.maximumdirect.net/releasekit/internal/git"
)
// Evidence is the identity captured when one publication was attempted.
type Evidence struct {
RepositoryDir string `json:"repository_dir,omitempty"`
ModuleDir string `json:"module_dir,omitempty"`
ModulePath string `json:"module_path,omitempty"`
Version string `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
CandidateCommit string `json:"candidate_commit,omitempty"`
TagObject string `json:"tag_object,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Remote string `json:"remote,omitempty"`
RecordedAt string `json:"recorded_at,omitempty"`
}
// Observation combines the original evidence with authoritative remote state.
type Observation struct {
Evidence Evidence
State State
ObservedTagObject string
ObservedCommit string
}
// RecoveryError describes why a later publication observation is unresolved.
type RecoveryError struct {
Kind string
RecordPath string
MissingFields []string
cause error
}
func (e *RecoveryError) Error() string {
if e == nil {
return ""
}
message := "publication recovery " + e.Kind
if e.cause != nil {
return message + ": " + git.DisplayError(e.cause).Error()
}
return message
}
func (e *RecoveryError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func classify(evidence Evidence, remoteTag git.RemoteRef) (Observation, error) {
result := Observation{Evidence: evidence, State: Unknown, ObservedTagObject: remoteTag.Object, ObservedCommit: remoteTag.Peeled}
missing := []string{}
if evidence.Endpoint == "" {
missing = append(missing, "endpoint")
}
if evidence.Tag == "" {
missing = append(missing, "tag")
}
if len(missing) > 0 {
return result, &RecoveryError{Kind: "missing_evidence", MissingFields: missing}
}
if remoteTag.State == git.RefAbsent {
result.State = NotPublished
return result, nil
}
if evidence.TagObject == "" {
return result, &RecoveryError{Kind: "missing_evidence", MissingFields: []string{"tag_object"}}
}
if remoteTag.State != git.RefPresent {
return result, &RecoveryError{Kind: "inconsistent_observation", cause: git.ErrRemoteStateUnreadable}
}
if !git.EqualObjectID(remoteTag.Object, evidence.TagObject) {
result.State = Conflict
return result, nil
}
if remoteTag.Peeled != "" {
if evidence.CandidateCommit == "" {
return result, &RecoveryError{Kind: "missing_evidence", MissingFields: []string{"candidate_commit"}}
}
if !git.EqualObjectID(remoteTag.Peeled, evidence.CandidateCommit) {
result.State = Conflict
return result, nil
}
}
result.State = Published
return result, nil
}
func observe(ctx context.Context, evidence Evidence) (Observation, error) {
if err := ctx.Err(); err != nil {
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "canceled", cause: err}
}
if evidence.Endpoint == "" || evidence.Tag == "" {
return classify(evidence, git.RemoteRef{})
}
if evidence.TagObject != "" && !git.ValidObjectID(evidence.TagObject) {
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
}
if evidence.CandidateCommit != "" && !git.ValidObjectID(evidence.CandidateCommit) {
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
}
valid, err := git.ValidateTagRef(ctx, evidence.RepositoryDir, evidence.Tag)
if err != nil {
return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
}
if !valid {
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
}
endpoint, err := git.ObserveEndpoint(ctx, evidence.RepositoryDir, evidence.Endpoint, evidence.Remote)
if err != nil {
return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
}
ref, err := endpoint.QueryTag(ctx, evidence.RepositoryDir, evidence.Tag)
if err != nil {
return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
}
result, err := classify(evidence, ref)
if err != nil && ctx.Err() != nil {
return result, &RecoveryError{Kind: "canceled", cause: ctx.Err()}
}
return result, err
}
func observationFailure(ctx context.Context, err error) *RecoveryError {
kind := "remote_observation"
switch {
case errors.Is(err, git.ErrEndpointChanged):
kind = "endpoint_changed"
case errors.Is(err, git.ErrTransportConfiguration):
kind = "transport_configuration"
}
return recoveryFailure(ctx, kind, err)
}
// Reconcile observes the recorded endpoint and classifies its exact tag.
func Reconcile(ctx context.Context, evidence Evidence) (Observation, error) {
return observe(ctx, evidence)
}
// recoveryFailure classifies failed operations while preserving caller cancellation.
func recoveryFailure(ctx context.Context, kind string, err error) *RecoveryError {
if contextErr := ctx.Err(); contextErr != nil {
return &RecoveryError{Kind: "canceled", cause: contextErr}
}
return &RecoveryError{Kind: kind, cause: err}
}

View File

@@ -0,0 +1,59 @@
package publish
import (
"errors"
"strings"
"testing"
"go.maximumdirect.net/releasekit/internal/git"
)
func TestClassifyPublicationObservation(t *testing.T) {
tagObject, commit := strings.Repeat("a", 40), strings.Repeat("b", 40)
base := Evidence{Endpoint: "file:///remote.git", Tag: "v1.2.3", TagObject: tagObject, CandidateCommit: commit}
tests := []struct {
name string
evidence Evidence
ref git.RemoteRef
want State
wantKind string
}{
{"absent with partial identity", Evidence{Endpoint: base.Endpoint, Tag: base.Tag}, git.RemoteRef{State: git.RefAbsent}, NotPublished, ""},
{"matching tag without peel", base, git.RemoteRef{State: git.RefPresent, Object: tagObject}, Published, ""},
{"matching tag and commit", base, git.RemoteRef{State: git.RefPresent, Object: strings.ToUpper(tagObject), Peeled: strings.ToUpper(commit)}, Published, ""},
{"tag conflict", base, git.RemoteRef{State: git.RefPresent, Object: strings.Repeat("c", 40), Peeled: commit}, Conflict, ""},
{"commit conflict", base, git.RemoteRef{State: git.RefPresent, Object: tagObject, Peeled: strings.Repeat("c", 40)}, Conflict, ""},
{"missing tag object", Evidence{Endpoint: base.Endpoint, Tag: base.Tag}, git.RemoteRef{State: git.RefPresent, Object: tagObject}, Unknown, "missing_evidence"},
{"missing commit with peel", Evidence{Endpoint: base.Endpoint, Tag: base.Tag, TagObject: tagObject}, git.RemoteRef{State: git.RefPresent, Object: tagObject, Peeled: commit}, Unknown, "missing_evidence"},
{"unreadable", base, git.RemoteRef{State: git.RefUnreadable}, Unknown, "inconsistent_observation"},
{"inconsistent", base, git.RemoteRef{State: git.RefConflict}, Unknown, "inconsistent_observation"},
{"missing endpoint", Evidence{Tag: base.Tag}, git.RemoteRef{State: git.RefAbsent}, Unknown, "missing_evidence"},
{"missing tag", Evidence{Endpoint: base.Endpoint}, git.RemoteRef{State: git.RefAbsent}, Unknown, "missing_evidence"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := classify(test.evidence, test.ref)
if got.State != test.want {
t.Fatalf("state=%q want %q", got.State, test.want)
}
var recovery *RecoveryError
if test.wantKind == "" {
if err != nil {
t.Fatalf("error=%v", err)
}
return
}
if !errors.As(err, &recovery) || recovery.Kind != test.wantKind {
t.Fatalf("error=%v want kind %q", err, test.wantKind)
}
})
}
}
func TestClassifyReportsAllMissingIdentityFields(t *testing.T) {
_, err := classify(Evidence{}, git.RemoteRef{State: git.RefPresent})
var recovery *RecoveryError
if !errors.As(err, &recovery) || strings.Join(recovery.MissingFields, ",") != "endpoint,tag" {
t.Fatalf("classification error = %#v", err)
}
}

224
internal/publish/record.go Normal file
View File

@@ -0,0 +1,224 @@
package publish
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"go.maximumdirect.net/releasekit/internal/git"
)
const recordFormatVersion = 1
const recordByteFormatVersion = 2
type recordEnvelope struct {
FormatVersion int `json:"format_version"`
Evidence json.RawMessage `json:"evidence"`
EvidenceBytes json.RawMessage `json:"evidence_bytes,omitempty"`
}
// LoadEvidence reads one complete supported private publication record.
func LoadEvidence(path string) (Evidence, error) {
data, err := os.ReadFile(path)
if err != nil {
return Evidence{}, &RecoveryError{Kind: "record_read", RecordPath: path, cause: fmt.Errorf("read record %q: %w", path, err)}
}
var envelope recordEnvelope
if err := json.Unmarshal(data, &envelope); err != nil {
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record %q: %w", path, err)}
}
if envelope.FormatVersion != recordFormatVersion && envelope.FormatVersion != recordByteFormatVersion {
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has unsupported format version %d", path, envelope.FormatVersion)}
}
if len(envelope.Evidence) == 0 || string(envelope.Evidence) == "null" {
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has no evidence object", path)}
}
var evidence Evidence
if err := json.Unmarshal(envelope.Evidence, &evidence); err != nil {
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record evidence %q: %w", path, err)}
}
if envelope.FormatVersion == recordByteFormatVersion && len(envelope.EvidenceBytes) > 0 {
var fields map[string]json.RawMessage
if err := json.Unmarshal(envelope.EvidenceBytes, &fields); err != nil {
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record evidence bytes %q: %w", path, err)}
}
for _, field := range evidenceFields(&evidence) {
if raw, ok := fields[field.name]; ok {
var encoded *string
if err := json.Unmarshal(raw, &encoded); err != nil {
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record field %s bytes %q: %w", field.name, path, err)}
}
if encoded == nil {
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record field %s bytes %q must be a base64 string", field.name, path)}
}
bytes, err := base64.StdEncoding.DecodeString(*encoded)
if err != nil {
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record field %s bytes %q: %w", field.name, path, err)}
}
*field.value = string(bytes)
}
}
}
return evidence, nil
}
type evidenceField struct {
name string
value *string
}
func evidenceFields(e *Evidence) [10]evidenceField {
return [10]evidenceField{
{"repository_dir", &e.RepositoryDir}, {"module_dir", &e.ModuleDir},
{"module_path", &e.ModulePath}, {"version", &e.Version}, {"tag", &e.Tag},
{"candidate_commit", &e.CandidateCommit}, {"tag_object", &e.TagObject},
{"endpoint", &e.Endpoint}, {"remote", &e.Remote}, {"recorded_at", &e.RecordedAt},
}
}
func encodeRecord(evidence Evidence) ([]byte, error) {
envelope := recordEnvelope{FormatVersion: recordFormatVersion}
var bytes map[string][]byte
for _, field := range evidenceFields(&evidence) {
if !utf8.ValidString(*field.value) {
if bytes == nil {
bytes = make(map[string][]byte)
}
bytes[field.name] = []byte(*field.value)
*field.value = ""
}
}
var err error
if bytes != nil {
envelope.FormatVersion = recordByteFormatVersion
envelope.EvidenceBytes, err = json.Marshal(bytes)
if err != nil {
return nil, err
}
}
envelope.Evidence, err = json.Marshal(evidence)
if err != nil {
return nil, err
}
return json.Marshal(envelope)
}
// SaveEvidence writes one private attempt record and returns its absolute path.
func SaveEvidence(ctx context.Context, repositoryDir, override string, evidence Evidence) (string, error) {
recordDir, root, err := resolveRecordDir(ctx, repositoryDir, override)
if err != nil {
return "", &RecoveryError{Kind: "record_write", cause: err}
}
return saveEvidenceInDir(ctx, repositoryDir, root, override, recordDir, evidence)
}
func saveEvidenceInDir(ctx context.Context, repositoryDir, root, override, recordDir string, evidence Evidence) (string, error) {
return saveEvidenceNamed(ctx, repositoryDir, root, override, recordDir, evidence, proposedRecordPath)
}
func saveEvidenceNamed(ctx context.Context, repositoryDir, root, override, recordDir string, evidence Evidence, nextPath func(string) (string, error)) (string, error) {
if evidence.RecordedAt == "" {
evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano)
}
if err := os.MkdirAll(recordDir, 0o700); err != nil {
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("create record directory %q: %w", recordDir, err)}
}
data, err := encodeRecord(evidence)
if err != nil {
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("encode publication evidence: %w", err)}
}
for attempt := 0; attempt < 5; attempt++ {
path, err := nextPath(recordDir)
if err != nil {
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("name publication record: %w", err)}
}
if override != "" && root != "" {
if err := rejectUnignoredWorktreePath(ctx, repositoryDir, root, path); err != nil {
return "", &RecoveryError{Kind: "record_write", cause: err}
}
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("create record %q: %w", path, err)}
}
if _, err = file.Write(data); err != nil {
_ = file.Close()
_ = os.Remove(path)
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("write record %q: %w", path, err)}
}
if err = file.Close(); err != nil {
_ = os.Remove(path)
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("close record %q: %w", path, err)}
}
return path, nil
}
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("could not allocate a unique record name in %q", recordDir)}
}
func resolveRecordDir(ctx context.Context, repositoryDir, override string) (string, string, error) {
root, err := git.RepositoryRoot(ctx, repositoryDir)
if err != nil {
return "", "", err
}
if override != "" {
if filepath.IsAbs(override) {
return filepath.Clean(override), root, nil
}
return filepath.Join(root, override), root, nil
}
common, err := git.CommonDirectory(ctx, repositoryDir)
if err != nil {
return "", "", err
}
return filepath.Join(common, "releasekit", "publication-attempts"), root, nil
}
func proposedRecordPath(directory string) (string, error) {
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", err
}
return filepath.Join(directory, "attempt-"+hex.EncodeToString(nonce[:])+".json"), nil
}
func rejectUnignoredWorktreePath(ctx context.Context, repositoryDir, root, proposed string) error {
parent, err := filepath.EvalSymlinks(filepath.Dir(proposed))
if err != nil {
return fmt.Errorf("resolve record directory %q: %w", filepath.Dir(proposed), err)
}
actual := filepath.Join(parent, filepath.Base(proposed))
rel, err := filepath.Rel(root, actual)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil
}
metadata, err := git.MetadataDirectories(ctx, repositoryDir)
if err != nil {
return err
}
for _, directory := range metadata {
rel, err := filepath.Rel(directory, actual)
if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil
}
}
ignored, err := git.IsIgnored(ctx, repositoryDir, actual)
if err != nil {
return fmt.Errorf("check Git ignore rules for record path %q: %w", actual, err)
}
if !ignored {
return fmt.Errorf("record path %q is inside the worktree and is not ignored", actual)
}
return nil
}

View File

@@ -0,0 +1,537 @@
package publish
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
)
func TestLoadEvidenceValidatesKnownByteFieldsAsBase64Strings(t *testing.T) {
for _, test := range []struct{ name, value, want, failure string }{
{"valid", `"djEuMi4z"`, "v1.2.3", ""},
{"empty", `""`, "", ""},
{"malformed base64", `"not base64!"`, "", "base64"},
{"numeric array", `[118,49,46,50,46,51]`, "", "type"},
{"object", `{}`, "", "type"},
{"boolean", `true`, "", "type"},
{"number", `123`, "", "type"},
{"null", `null`, "", "schema"},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "record.json")
contents := `{"format_version":2,"evidence":{},"evidence_bytes":{"tag":` + test.value + `}}`
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
t.Fatal(err)
}
loaded, err := LoadEvidence(path)
if test.failure == "" {
if err != nil || loaded.Tag != test.want {
t.Fatalf("byte string = %#v, %v", loaded, err)
}
return
}
var typed *RecoveryError
if !errors.As(err, &typed) || typed.Kind != "record_format" || typed.Unwrap() == nil {
t.Fatalf("byte format failure = %v", err)
}
switch test.failure {
case "type":
var cause *json.UnmarshalTypeError
if !errors.As(err, &cause) {
t.Fatalf("lost JSON type cause: %v", err)
}
case "base64":
var cause base64.CorruptInputError
if !errors.As(err, &cause) {
t.Fatalf("lost base64 cause: %v", err)
}
}
})
}
}
func TestRecordsRoundTripNonUTF8Evidence(t *testing.T) {
repo := recordRepo(t)
raw := "path-\xff"
evidence := Evidence{RepositoryDir: raw, ModuleDir: raw, ModulePath: raw, Version: raw, Tag: raw, CandidateCommit: raw, TagObject: raw, Endpoint: raw, Remote: raw, RecordedAt: raw}
path, err := SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
if err != nil {
t.Fatal(err)
}
loaded, err := LoadEvidence(path)
if err != nil || loaded != evidence {
t.Fatalf("lossy evidence: %#v, %v", loaded, err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var envelope recordEnvelope
if err := json.Unmarshal(data, &envelope); err != nil || envelope.FormatVersion != recordByteFormatVersion {
t.Fatalf("byte record envelope: %#v, %v", envelope, err)
}
// Unknown fields remain ignorable; known byte fields retain real codec errors.
for _, test := range []struct {
field string
wantError bool
}{{"endpoint", true}, {"future_field", false}} {
envelope.EvidenceBytes = json.RawMessage(`{"` + test.field + `":"not base64!"}`)
data, err := json.Marshal(envelope)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
_, err = LoadEvidence(path)
if (err != nil) != test.wantError {
t.Fatalf("byte field %s error=%v", test.field, err)
}
}
}
func TestRecordLoadedObservationKeepsNonUTF8EndpointIdentity(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("raw pathname bytes are a POSIX behavior")
}
repo := recordRepo(t)
base := t.TempDir()
original := filepath.Join(base, "remote-\xff.git")
decoy := filepath.Join(base, "remote-\uFFFD.git")
for _, remote := range []string{original, decoy} {
recordGit(t, repo, "init", "--bare", remote)
}
recordGit(t, repo, "tag", "-a", "v1.2.3", "-m", "Release")
recordGit(t, repo, "push", decoy, "refs/tags/v1.2.3")
evidence := Evidence{RepositoryDir: repo, ModuleDir: repo, Endpoint: original, Tag: "v1.2.3", TagObject: strings.TrimSpace(recordGit(t, repo, "rev-parse", "v1.2.3")), CandidateCommit: strings.TrimSpace(recordGit(t, repo, "rev-parse", "HEAD"))}
path, err := SaveEvidence(t.Context(), repo, "", evidence)
if err != nil {
t.Fatal(err)
}
loaded, err := LoadEvidence(path)
if err != nil || loaded.Endpoint != original {
t.Fatalf("endpoint changed: %q, %v", loaded.Endpoint, err)
}
for _, selected := range []Evidence{evidence, loaded} {
observed, err := Reconcile(t.Context(), selected)
if err != nil || observed.State != NotPublished {
t.Fatalf("original endpoint observation = %#v, %v", observed, err)
}
}
loaded.Endpoint = decoy
observed, err := Reconcile(t.Context(), loaded)
if err != nil || observed.State != Published {
t.Fatalf("decoy does not distinguish identity: %#v, %v", observed, err)
}
}
func recordGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return string(out)
}
func recordRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
recordGit(t, dir, "init", "-q")
recordGit(t, dir, "config", "user.name", "Test")
recordGit(t, dir, "config", "user.email", "test@example.invalid")
if err := os.WriteFile(filepath.Join(dir, "file"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
recordGit(t, dir, "add", "file")
recordGit(t, dir, "commit", "-qm", "init")
return dir
}
func TestLoadEvidenceAcceptsPartialAndRejectsMalformedRecords(t *testing.T) {
dir := t.TempDir()
partial := filepath.Join(dir, "partial.json")
if err := os.WriteFile(partial, []byte(`{"format_version":1,"evidence":{}}`), 0600); err != nil {
t.Fatal(err)
}
got, err := LoadEvidence(partial)
if err != nil || got != (Evidence{}) {
t.Fatalf("partial record = %#v, %v", got, err)
}
for name, data := range map[string]string{"bad-json": "{", "unsupported": `{"format_version":3,"evidence":{}}`, "type": `{"format_version":1,"evidence":{"tag":false}}`, "trailing": `{"format_version":1,"evidence":{}} {}`} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatal(err)
}
_, err := LoadEvidence(path)
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "record_format" {
t.Fatalf("LoadEvidence() error=%v", err)
}
})
}
}
func TestLoadEvidenceRequiresOneDocumentAndPreservesSyntaxCause(t *testing.T) {
const valid = `{"format_version":1,"evidence":{"tag":"v1.2.3","future":{}},"future":true}`
for _, test := range []struct {
name, data string
valid bool
}{
{"single", valid, true}, {"whitespace", valid + "\n \t\r", true},
{"first incomplete", "{", false}, {"first syntax", "x", false},
{"second object", valid + ` {"unused":[1,2,3]}`, false},
{"incomplete suffix", valid + " {", false}, {"invalid suffix", valid + " x", false},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "record.json")
if err := os.WriteFile(path, []byte(test.data), 0600); err != nil {
t.Fatal(err)
}
loaded, err := LoadEvidence(path)
if test.valid {
if err != nil || loaded.Tag != "v1.2.3" {
t.Fatalf("valid record = %#v, %v", loaded, err)
}
return
}
var recovery *RecoveryError
var syntax *json.SyntaxError
if !errors.As(err, &recovery) || recovery.Kind != "record_format" || !errors.As(err, &syntax) {
t.Fatalf("lost format/parser cause: %v", err)
}
})
}
}
func TestLoadEvidenceReadFailureHasTypedError(t *testing.T) {
_, err := LoadEvidence(filepath.Join(t.TempDir(), "missing.json"))
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "record_read" || !errors.Is(err, os.ErrNotExist) {
t.Fatalf("LoadEvidence() error = %v", err)
}
}
func TestSaveEvidenceUsesPrivateUniqueRecordsAndLinkedWorktreeMetadata(t *testing.T) {
repo := recordRepo(t)
worktree := filepath.Join(t.TempDir(), "linked")
recordGit(t, repo, "worktree", "add", "--detach", worktree)
evidence := Evidence{Tag: "v1.2.3", Endpoint: "https://user:secret@example.invalid/repo.git"}
first, err := SaveEvidence(t.Context(), worktree, "", evidence)
if err != nil {
t.Fatal(err)
}
second, err := SaveEvidence(t.Context(), worktree, "", evidence)
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatalf("record paths collided: %q", first)
}
common := recordGit(t, repo, "rev-parse", "--path-format=absolute", "--git-common-dir")
wantDir := filepath.Join(strings.TrimSpace(common), "releasekit", "publication-attempts")
if filepath.Dir(first) != wantDir {
t.Fatalf("record dir=%q want %q", filepath.Dir(first), wantDir)
}
dirInfo, err := os.Stat(wantDir)
if err != nil {
t.Fatal(err)
}
if dirInfo.Mode().Perm() != 0700 {
t.Fatalf("record directory permissions=%o", dirInfo.Mode().Perm())
}
info, err := os.Stat(first)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("record permissions=%o", info.Mode().Perm())
}
loaded, err := LoadEvidence(first)
if err != nil {
t.Fatal(err)
}
if loaded.Endpoint != evidence.Endpoint || loaded.RecordedAt == "" {
t.Fatalf("loaded evidence=%#v", loaded)
}
var envelope map[string]json.RawMessage
data, err := os.ReadFile(first)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "user:secret@example.invalid") {
t.Fatal("private record did not retain the exact endpoint")
}
}
func TestSaveEvidenceResolvesRelativeAndIgnoredOverrides(t *testing.T) {
repo := recordRepo(t)
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("saved-records/\n"), 0600); err != nil {
t.Fatal(err)
}
if _, err := SaveEvidence(t.Context(), repo, "saved-records", Evidence{Tag: "v1.2.3"}); err != nil {
t.Fatalf("ignored override: %v", err)
}
if _, err := os.Stat(filepath.Join(repo, "saved-records")); err != nil {
t.Fatal(err)
}
external := filepath.Join(t.TempDir(), "external-store")
path, err := SaveEvidence(t.Context(), repo, external, Evidence{Tag: "v2.0.0"})
if err != nil || !strings.HasPrefix(path, external+string(filepath.Separator)) {
t.Fatalf("absolute external override path=%q error=%v", path, err)
}
}
func TestSaveEvidenceAllowsExplicitGitMetadataStores(t *testing.T) {
for _, layout := range []string{"ordinary", "linked", "separate"} {
t.Run(layout, func(t *testing.T) {
repo := recordRepo(t)
switch layout {
case "linked":
linked := filepath.Join(t.TempDir(), "linked")
recordGit(t, repo, "worktree", "add", "--detach", linked)
repo = linked
case "separate":
recordGit(t, repo, "init", "--separate-git-dir", filepath.Join(t.TempDir(), "metadata"))
}
defaultPath, err := SaveEvidence(t.Context(), repo, "", Evidence{})
if err != nil {
t.Fatal(err)
}
privateDir := strings.TrimSuffix(recordGit(t, repo, "rev-parse", "--absolute-git-dir"), "\n")
for _, directory := range []string{filepath.Dir(defaultPath), filepath.Join(privateDir, "other-private-records")} {
path, err := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v1.2.3"})
if err != nil || filepath.Dir(path) != directory {
t.Fatalf("explicit metadata store %q = %q, %v", directory, path, err)
}
}
if status := recordGit(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("metadata records appeared in candidate: %s", status)
}
})
}
}
func TestRecordStoragePreservesWhitespaceInRepositoryAndMetadataPaths(t *testing.T) {
for _, separate := range []bool{false, true} {
t.Run(fmt.Sprintf("separate=%t", separate), func(t *testing.T) {
original := recordRepo(t)
repo := original + " "
if err := os.Rename(original, repo); err != nil {
t.Fatal(err)
}
// recordRepo's temporary-directory cleanup owns the old spelling.
t.Cleanup(func() { _ = os.RemoveAll(repo) })
if separate {
recordGit(t, repo, "init", "--separate-git-dir", filepath.Join(t.TempDir(), "metadata "))
}
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("saved/\n"), 0600); err != nil {
t.Fatal(err)
}
recordGit(t, repo, "add", ".gitignore")
recordGit(t, repo, "commit", "-qm", "Ignore saved records")
for _, override := range []string{"", "saved"} {
path, err := SaveEvidence(t.Context(), repo, override, Evidence{RepositoryDir: repo, Tag: "v1.2.3"})
if err != nil {
t.Fatal(err)
}
listed, err := ListAttempts(t.Context(), repo, override)
if err != nil || len(listed.Attempts) != 1 || listed.Attempts[0].RecordPath != path || listed.Attempts[0].Evidence.RepositoryDir != repo {
t.Fatalf("read/write disagreement for %q: %#v, %v", override, listed, err)
}
if override != "" && filepath.Dir(path) != filepath.Join(repo, override) {
t.Fatalf("changed repository path: %q", path)
}
}
if _, err := SaveEvidence(t.Context(), repo, filepath.Join(repo, "unignored"), Evidence{}); err == nil {
t.Fatal("absolute unignored store was misclassified as external")
}
if status := recordGit(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("recording changed candidate: %s", status)
}
})
}
}
func TestSaveEvidenceRejectsUnignoredOverrideInsideWorktree(t *testing.T) {
repo := recordRepo(t)
_, err := SaveEvidence(t.Context(), repo, "records", Evidence{Tag: "v1.2.3"})
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "record_write" {
t.Fatalf("SaveEvidence() error=%v", err)
}
if _, err := os.Stat(filepath.Join(repo, "records")); err != nil {
t.Fatalf("expected parent directory to exist without record: %v", err)
}
if _, err := os.Stat(filepath.Join(repo, "records", "attempt")); !os.IsNotExist(err) {
t.Fatalf("unexpected record: %v", err)
}
}
func TestSaveEvidenceChecksEachActualFilename(t *testing.T) {
repo := recordRepo(t)
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("selective/attempt-[0-7]*.json\n"), 0600); err != nil {
t.Fatal(err)
}
recordGit(t, repo, "add", ".gitignore")
recordGit(t, repo, "commit", "-qm", "Selective ignore")
dir := filepath.Join(repo, "selective")
next := func(name string) func(string) (string, error) {
return func(directory string) (string, error) { return filepath.Join(directory, name), nil }
}
path, err := saveEvidenceNamed(t.Context(), repo, repo, dir, dir, Evidence{Tag: "v1.0.0"}, next("attempt-0.json"))
if err != nil {
t.Fatal(err)
}
recordGit(t, repo, "check-ignore", "--", path)
// A collision must validate the replacement name as well.
calls := 0
_, err = saveEvidenceNamed(t.Context(), repo, repo, dir, dir, Evidence{Tag: "v2.0.0"}, func(directory string) (string, error) {
calls++
name := "attempt-0.json"
if calls > 1 {
name = "attempt-c.json"
}
return filepath.Join(directory, name), nil
})
if err == nil || calls != 2 {
t.Fatalf("unignored replacement: calls=%d error=%v", calls, err)
}
if _, err := os.Stat(filepath.Join(dir, "attempt-c.json")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unignored file was created: %v", err)
}
if status := recordGit(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("recording dirtied candidate: %s", status)
}
loaded, err := LoadEvidence(path)
if err != nil || loaded.Tag != "v1.0.0" {
t.Fatalf("collision changed original record: %#v, %v", loaded, err)
}
}
func TestListAttemptsReturnsReadableRecordsAndDiagnosticsInPathOrder(t *testing.T) {
repo := recordRepo(t)
directory := filepath.Join(t.TempDir(), "attempts")
if err := os.Mkdir(directory, 0700); err != nil {
t.Fatal(err)
}
second, _ := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v2.0.0"})
first, _ := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v1.0.0"})
if err := os.WriteFile(filepath.Join(directory, "broken.json"), []byte("{"), 0600); err != nil {
t.Fatal(err)
}
listed, err := ListAttempts(t.Context(), repo, directory)
if err != nil || len(listed.Attempts) != 2 || len(listed.Diagnostics) != 1 {
t.Fatalf("list=%#v error=%v", listed, err)
}
wantPaths := []string{first, second}
sort.Strings(wantPaths)
if listed.Attempts[0].RecordPath != wantPaths[0] || listed.Attempts[1].RecordPath != wantPaths[1] {
t.Fatalf("attempt order=%v", listed.Attempts)
}
if listed.Diagnostics[0].Kind != "record_format" || listed.Diagnostics[0].RecordPath == "" {
t.Fatalf("diagnostic=%#v", listed.Diagnostics[0])
}
}
func TestListAttemptsFiltersMixedDirectoryAcrossBatches(t *testing.T) {
directory := t.TempDir()
for i := range 400 {
if err := os.WriteFile(filepath.Join(directory, fmt.Sprintf("unrelated-%04d.txt", i)), []byte("not a record"), 0600); err != nil {
t.Fatal(err)
}
}
if err := os.Mkdir(filepath.Join(directory, "directory.json"), 0700); err != nil {
t.Fatal(err)
}
names := []string{"z.json", "b.json", "a.json"}
for _, name := range names {
if err := os.WriteFile(filepath.Join(directory, name), []byte(`{"format_version":1,"evidence":{"tag":"v1.2.3"}}`), 0600); err != nil {
t.Fatal(err)
}
}
listed, err := ListAttempts(t.Context(), "", directory)
if err != nil || len(listed.Diagnostics) != 0 || len(listed.Attempts) != len(names) {
t.Fatalf("filtered result = %#v, %v", listed, err)
}
sort.Strings(names)
for i, name := range names {
if listed.Attempts[i].RecordPath != filepath.Join(directory, name) {
t.Fatalf("incorrect path order: %#v", listed.Attempts)
}
}
}
func TestListAttemptsRetainsEntriesWithEnumerationFailure(t *testing.T) {
directory := t.TempDir()
for name, contents := range map[string]string{
"z.json": `{"format_version":1,"evidence":{"tag":"v2.0.0"}}`,
"a.json": `{"format_version":1,"evidence":{"tag":"v1.0.0"}}`,
"broken.json": "{", "unrelated.txt": "ignored",
} {
if err := os.WriteFile(filepath.Join(directory, name), []byte(contents), 0600); err != nil {
t.Fatal(err)
}
}
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
failure := errors.New("controlled directory read failure")
for _, empty := range []bool{false, true} {
calls := 0
listed, err := listAttempts(t.Context(), "", directory, func(ctx context.Context, dir string) ([]string, error) {
return readRecordPaths(ctx, dir, func(int) ([]os.DirEntry, error) {
calls++
if empty {
return nil, failure
}
return entries, failure
})
})
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "storage_enumeration" || !errors.Is(err, failure) || calls != 1 {
t.Fatalf("enumeration failure lost: %#v, %v; reads=%d", listed, err, calls)
}
if empty {
if len(listed.Attempts) != 0 || len(listed.Diagnostics) != 0 {
t.Fatalf("empty read: %#v", listed)
}
} else if len(listed.Attempts) != 2 || len(listed.Diagnostics) != 1 || filepath.Base(listed.Attempts[0].RecordPath) != "a.json" || filepath.Base(listed.Attempts[1].RecordPath) != "z.json" || listed.Diagnostics[0].Kind != "record_format" {
t.Fatalf("partial read dropped/reordered records: %#v", listed)
}
}
}
func TestListAttemptsDistinguishesEmptyMissingAndCanceledStorage(t *testing.T) {
empty := t.TempDir()
listed, err := ListAttempts(t.Context(), "", empty)
if err != nil || len(listed.Attempts) != 0 || len(listed.Diagnostics) != 0 {
t.Fatalf("empty list=%#v error=%v", listed, err)
}
_, err = ListAttempts(t.Context(), "", filepath.Join(t.TempDir(), "missing"))
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "storage_enumeration" {
t.Fatalf("missing storage error=%v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = ListAttempts(ctx, "", empty)
if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) {
t.Fatalf("canceled error=%v", err)
}
}

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

@@ -6,18 +6,23 @@ import (
"go.maximumdirect.net/releasekit/internal/publish" "go.maximumdirect.net/releasekit/internal/publish"
) )
// PublicationState describes one publication attempt or a later observation.
type PublicationState string type PublicationState string
const ( const (
// PublicationNotAttempted means no tag push was attempted. // PublicationNotAttempted means no tag push was attempted.
PublicationNotAttempted PublicationState = PublicationState(publish.NotAttempted) PublicationNotAttempted PublicationState = PublicationState(publish.NotAttempted)
// PublicationNotPublished means this invocation knows the tag was not published. // PublicationNotPublished means the operation established that its tag was
// not published or was absent at observation time. It does not establish that
// the version was never published.
PublicationNotPublished PublicationState = PublicationState(publish.NotPublished) PublicationNotPublished PublicationState = PublicationState(publish.NotPublished)
// PublicationPublished means Git reported a successful push of the intended tag object. // PublicationPublished means the intended tag object was pushed successfully
// or observed remotely.
PublicationPublished PublicationState = PublicationState(publish.Published) PublicationPublished PublicationState = PublicationState(publish.Published)
// PublicationConflict means an authoritative branch or tag guard failed. // PublicationConflict means an authoritative guard or later observation found
// conflicting state.
PublicationConflict PublicationState = PublicationState(publish.Conflict) PublicationConflict PublicationState = PublicationState(publish.Conflict)
// PublicationUnknown means a tag push failed and its remote outcome is unresolved. // PublicationUnknown means publication could not be determined from the available evidence.
PublicationUnknown PublicationState = PublicationState(publish.Unknown) PublicationUnknown PublicationState = PublicationState(publish.Unknown)
) )
@@ -32,7 +37,7 @@ const (
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation) PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
// PublicationPush identifies the remote tag push attempt. // PublicationPush identifies the remote tag push attempt.
PublicationPush PublicationPhase = PublicationPhase(publish.Push) PublicationPush PublicationPhase = PublicationPhase(publish.Push)
// PublicationReconciliation identifies the single authoritative tag query after a push error. // PublicationReconciliation identifies immediate observation after a push error.
PublicationReconciliation PublicationPhase = PublicationPhase(publish.Reconciliation) PublicationReconciliation PublicationPhase = PublicationPhase(publish.Reconciliation)
// PublicationComplete identifies a successful tag push. // PublicationComplete identifies a successful tag push.
PublicationComplete PublicationPhase = PublicationPhase(publish.Complete) PublicationComplete PublicationPhase = PublicationPhase(publish.Complete)
@@ -40,16 +45,19 @@ const (
// PublicationResult describes the publication state reached by this call. // PublicationResult describes the publication state reached by this call.
type PublicationResult struct { type PublicationResult struct {
RepositoryDir string // absolute source repository path. RepositoryDir string // absolute source repository path.
ModuleDir string // absolute selected module directory. ModuleDir string // absolute selected module directory.
ModulePath string // selected module's declared Go path. ModulePath string // selected module's declared Go path.
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
Evidence PublicationEvidence // typed evidence for later reconciliation; Endpoint may contain credentials
RecordPath string // absolute private record path when recording succeeds
RecordError error // recording failure, separate from publication outcome and returned error
} }
// After publication preflight succeeds, Publish runs one offline Go test suite // After publication preflight succeeds, Publish runs one offline Go test suite
@@ -74,7 +82,11 @@ type PublicationResult struct {
// tag query: an exact object match confirms publication, absence is // tag query: an exact object match confirms publication, absence is
// not_published, a different object is conflict, and an unreadable result is // not_published, a different object is conflict, and an unreadable result is
// unknown. A nil error means publication is confirmed by the successful push // unknown. A nil error means publication is confirmed by the successful push
// or exact remote object match. // or an exact remote object match. Publish records available attempt evidence
// before the tag push when local storage is available; recording failure is
// reported separately in PublicationResult.RecordError and does not change the
// publication outcome. PublicationResult.Evidence can be passed directly to
// ReconcilePublication even when no record was saved.
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) { func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
candidateInput := candidateRequest(request) candidateInput := candidateRequest(request)
result, err := publish.Publish(ctx, publish.Request{ result, err := publish.Publish(ctx, publish.Request{
@@ -84,6 +96,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
Version: candidateInput.Version, Version: candidateInput.Version,
Remote: request.Remote, Remote: request.Remote,
ExpectedPushURL: request.ExpectedPushURL, ExpectedPushURL: request.ExpectedPushURL,
RecordDir: request.PublicationRecordDir,
Validation: candidateInput.Validation, Validation: candidateInput.Validation,
AdditionalChecks: candidateInput.AdditionalChecks, AdditionalChecks: candidateInput.AdditionalChecks,
AfterValidate: candidateInput.AfterValidate, AfterValidate: candidateInput.AfterValidate,
@@ -100,5 +113,8 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
Phase: PublicationPhase(result.Phase), Phase: PublicationPhase(result.Phase),
TagObject: result.TagObject, TagObject: result.TagObject,
State: PublicationState(result.State), State: PublicationState(result.State),
Evidence: publicEvidence(result.Evidence),
RecordPath: result.RecordPath,
RecordError: publicRecoveryError(result.RecordError, ""),
}, convertValidationError(err) }, convertValidationError(err)
} }

View File

@@ -14,10 +14,115 @@ 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 TestPublishStopsOnReleaseNoteCaptureFailure(t *testing.T) {
for _, missing := range []bool{false, true} {
t.Run(fmt.Sprintf("missing=%t", missing), func(t *testing.T) {
repo := newPublishFixture(t)
if missing {
gitRun(t, repo.work, "rm", "docs/releases/v1.2.3.md")
} else {
writeFile(t, repo.work, "docs/releases/v1.2.3.md", "# Wrong Product v1.2.3\n")
gitRun(t, repo.work, "add", ".")
}
gitRun(t, repo.work, "commit", "-m", "Invalid note")
called := false
result, err := releasekit.Publish(t.Context(), releasekit.ReleaseRequest{
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
AdditionalChecks: func(context.Context, releasekit.Candidate) error { called = true; return nil },
})
if err == nil || called || result.State != releasekit.PublicationNotAttempted {
t.Fatalf("rejected candidate = %#v, %v; callback=%t", result, err, called)
}
if missing {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("missing note lost Git cause: %v", err)
}
}
if result.RecordError != nil || result.RecordPath == "" || result.Evidence.CandidateCommit == "" {
t.Fatalf("partial evidence was not saved: %#v", result)
}
if tags := gitText(t, repo.work, "tag", "--list", "v1.2.3"); tags != "" {
t.Fatalf("created tag for rejected candidate: %s", tags)
}
if tags := gitText(t, repo.work, "ls-remote", "origin", "refs/tags/v1.2.3"); tags != "" {
t.Fatalf("pushed tag for rejected candidate: %s", tags)
}
})
}
}
func TestPublishStorageResolutionFailureIsNonfatal(t *testing.T) {
repo := newPublishFixture(t)
realGit := mustLookPath(t, "git")
bin := t.TempDir()
wrapper := "#!/bin/sh\nfor arg in \"$@\"; do\n [ \"$arg\" = --git-common-dir ] && exit 71\ndone\nexec " + shellQuote(realGit) + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
called := false
result, err := releasekit.Publish(t.Context(), releasekit.ReleaseRequest{
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
AdditionalChecks: func(context.Context, releasekit.Candidate) error { called = true; return nil },
})
var exitErr *exec.ExitError
if err != nil || !called || result.State != releasekit.PublicationPublished || result.RecordPath != "" || !errors.As(result.RecordError, &exitErr) || exitErr.ExitCode() != 71 {
t.Fatalf("publication with failed storage lookup = %#v, %v; callback=%t", result, err, called)
}
if got := gitText(t, repo.work, "ls-remote", "origin", "refs/tags/v1.2.3"); !strings.Contains(got, result.TagObject) {
t.Fatalf("published object missing: %s", got)
}
}
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 +197,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,15 +229,19 @@ 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)
} }
if result.RepositoryDir != repo.work || result.ModuleDir != repo.work || result.ModulePath != "example.com/releasekit-publish-fixture" || result.Tag != "v1.2.3" || result.CandidateCommit != commit || result.Endpoint != repo.bare || result.TagObject == "" { if result.RepositoryDir != repo.work || result.ModuleDir != repo.work || result.ModulePath != "example.com/releasekit-publish-fixture" || result.Tag != "v1.2.3" || result.CandidateCommit != commit || result.Endpoint != repo.bare || result.TagObject == "" {
t.Fatalf("publication identity = %#v", result) t.Fatalf("publication identity = %#v", result)
} }
if result.RecordPath == "" || result.RecordError != nil {
t.Fatalf("publication record path=%q error=%v", result.RecordPath, result.RecordError)
}
recorded, err := releasekit.LoadPublicationEvidence(result.RecordPath)
if err != nil || recorded.Endpoint != repo.bare || recorded.Tag != result.Tag || recorded.CandidateCommit != commit || recorded.TagObject != result.TagObject || recorded.RecordedAt == "" {
t.Fatalf("saved publication evidence=%#v, error=%v", recorded, err)
}
refsAfter := gitText(t, repo.bare, "show-ref", "--heads", "--tags") refsAfter := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
for _, ref := range strings.Split(refsBefore, "\n") { for _, ref := range strings.Split(refsBefore, "\n") {
@@ -166,6 +275,51 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
} }
} }
func TestPublishKeepsSuccessWhenAttemptRecordCannotBeSaved(t *testing.T) {
repo := newPublishFixture(t)
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", PublicationRecordDir: "unignored-records",
AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil },
})
var recovery *releasekit.PublicationRecoveryError
if err != nil || result.State != releasekit.PublicationPublished {
t.Fatalf("publication=%#v error=%v", result, err)
}
if result.RecordPath != "" || !errors.As(result.RecordError, &recovery) || recovery.Kind != "record_write" {
t.Fatalf("record path=%q error=%v", result.RecordPath, result.RecordError)
}
}
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 +459,82 @@ 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)
}
evidence, loadErr := releasekit.LoadPublicationEvidence(result.RecordPath)
if loadErr != nil || evidence.Tag != result.Tag || evidence.CandidateCommit != result.CandidateCommit || evidence.TagObject != result.TagObject {
t.Fatalf("tag-inspection record=%#v error=%v", evidence, loadErr)
}
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:"
@@ -594,6 +824,9 @@ func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(t *testing.T) {
if result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" { if result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" {
t.Fatalf("publication result = %#v", result) t.Fatalf("publication result = %#v", result)
} }
if evidence, loadErr := releasekit.LoadPublicationEvidence(result.RecordPath); loadErr != nil || evidence.TagObject != result.TagObject || evidence.CandidateCommit != result.CandidateCommit {
t.Fatalf("rejected-push record = %#v, %v", evidence, loadErr)
}
if !strings.Contains(err.Error(), "failed to push some refs") || !strings.Contains(err.Error(), "authoritative tag is absent") { if !strings.Contains(err.Error(), "failed to push some refs") || !strings.Contains(err.Error(), "authoritative tag is absent") {
t.Fatalf("push error was not preserved with absence evidence: %v", err) t.Fatalf("push error was not preserved with absence evidence: %v", err)
} }
@@ -671,6 +904,9 @@ func TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown(t *testing.T
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err) t.Fatalf("result = %#v, error = %v", result, err)
} }
if evidence, loadErr := releasekit.LoadPublicationEvidence(result.RecordPath); loadErr != nil || evidence.TagObject != result.TagObject {
t.Fatalf("unknown-push record = %#v, %v", evidence, loadErr)
}
if !strings.Contains(err.Error(), "scoped URL resolves") || !strings.Contains(err.Error(), "lost acknowledgment") { if !strings.Contains(err.Error(), "scoped URL resolves") || !strings.Contains(err.Error(), "lost acknowledgment") {
t.Fatalf("error does not preserve endpoint verification and push failures: %v", err) t.Fatalf("error does not preserve endpoint verification and push failures: %v", err)
} }

153
recovery.go Normal file
View File

@@ -0,0 +1,153 @@
package releasekit
import (
"context"
"errors"
"fmt"
"go.maximumdirect.net/releasekit/internal/git"
"go.maximumdirect.net/releasekit/internal/publish"
)
// PublicationEvidence identifies one attempted publication independently of
// current repository state. Reconciliation uses RepositoryDir as Git's working
// directory, Tag and Endpoint as the observed ref and destination, and the
// captured object IDs to classify the observation. Endpoint may contain
// credentials; treat this value and saved records as sensitive.
type PublicationEvidence struct {
RepositoryDir string // Git working-directory context for relative endpoints; empty uses the caller's current directory.
ModuleDir string // physical module directory captured for this attempt.
ModulePath string // declared module path captured for this attempt.
Version string // semantic version selected for this attempt.
Tag string // complete Git tag ref selected for this attempt.
CandidateCommit string // candidate commit captured before publication.
TagObject string // annotated tag object ID captured before pushing, when available.
Endpoint string // exact effective push endpoint captured for this attempt; may contain credentials.
Remote string // remote name supplying supported transport configuration during later observation.
RecordedAt string // UTC RFC3339Nano time when this attempt's evidence was initialized, when available.
}
// PublicationReconciliationResult combines saved evidence with one later
// observation. It does not update local or remote state or modify the record.
type PublicationReconciliationResult struct {
Evidence PublicationEvidence // original selected evidence.
State PublicationState // published, not_published, conflict, or unknown.
ObservedTagObject string // observed remote tag-object ID, when available.
ObservedCommit string // observed peeled commit ID, when available.
}
// PublicationRecoveryError describes an unresolved recovery operation or a
// record/storage failure. Its Error text is credential-safe; Unwrap retains
// the underlying cause for errors.Is and errors.As.
// Kind includes missing_evidence, invalid_evidence, canceled, endpoint_changed,
// transport_configuration, remote_observation, inconsistent_observation,
// record_format, record_read, record_write, and storage_enumeration.
// endpoint_changed identifies a confirmed URL-resolution mismatch;
// transport_configuration identifies configuration-read or resolution failures,
// including when the original remote name is absent.
type PublicationRecoveryError struct {
Kind string
RecordPath string // selected or affected record path, when applicable.
MissingFields []string // private-record field names required for the attempted classification.
cause error
}
// Error returns the credential-safe recovery diagnostic.
func (e *PublicationRecoveryError) Error() string {
if e == nil {
return ""
}
message := fmt.Sprintf("publication recovery %s", e.Kind)
if e.RecordPath != "" {
message += fmt.Sprintf(" for %q", git.DisplayDiagnostic(e.RecordPath))
}
if e.cause != nil {
message += ": " + git.DisplayError(e.cause).Error()
}
return git.DisplayDiagnostic(message)
}
// Unwrap returns the underlying recovery or storage cause.
func (e *PublicationRecoveryError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
// ReconcilePublication observes the recorded endpoint without running candidate
// validation or changing local or remote state.
func ReconcilePublication(ctx context.Context, evidence PublicationEvidence) (PublicationReconciliationResult, error) {
observation, err := publish.Reconcile(ctx, internalEvidence(evidence))
return publicObservation(observation), publicRecoveryError(err, "")
}
// LoadPublicationEvidence loads one explicit private attempt record. Relative
// paths are interpreted from the caller's current directory; no Git lookup is
// performed.
func LoadPublicationEvidence(path string) (PublicationEvidence, error) {
evidence, err := publish.LoadEvidence(path)
return publicEvidence(evidence), publicRecoveryError(err, path)
}
// PublicationAttemptsRequest selects local publication-attempt storage.
type PublicationAttemptsRequest struct {
RepositoryDir string // repository used to resolve an empty or relative RecordDir; empty means the current directory.
RecordDir string // optional storage directory; relative paths use the repository root, and absolute paths can be listed outside a checkout.
}
// PublicationAttempt pairs one readable record path with the evidence it stores.
type PublicationAttempt struct {
RecordPath string // absolute path of the selected record.
Evidence PublicationEvidence // saved identity, including any partial fields.
}
// PublicationAttemptsResult contains readable attempts and per-record
// diagnostics from one local directory enumeration.
type PublicationAttemptsResult struct {
RecordDir string // resolved absolute directory selected for enumeration, when available.
Attempts []PublicationAttempt // readable records in deterministic path order.
Diagnostics []*PublicationRecoveryError // unreadable or unsupported records; other entries remain available.
}
// ListPublicationAttempts discovers saved local records without querying a
// remote or inferring which attempt should be reconciled.
func ListPublicationAttempts(ctx context.Context, request PublicationAttemptsRequest) (PublicationAttemptsResult, error) {
listed, err := publish.ListAttempts(ctx, request.RepositoryDir, request.RecordDir)
return publicAttemptsResult(listed, err)
}
func publicAttemptsResult(listed publish.AttemptsResult, err error) (PublicationAttemptsResult, error) {
result := PublicationAttemptsResult{RecordDir: listed.RecordDir, Attempts: make([]PublicationAttempt, 0, len(listed.Attempts)), Diagnostics: make([]*PublicationRecoveryError, 0, len(listed.Diagnostics))}
for _, attempt := range listed.Attempts {
result.Attempts = append(result.Attempts, PublicationAttempt{RecordPath: attempt.RecordPath, Evidence: publicEvidence(attempt.Evidence)})
}
for _, diagnostic := range listed.Diagnostics {
converted := publicRecoveryError(diagnostic, diagnostic.RecordPath)
if recovery, ok := converted.(*PublicationRecoveryError); ok {
result.Diagnostics = append(result.Diagnostics, recovery)
}
}
return result, publicRecoveryError(err, "")
}
func internalEvidence(e PublicationEvidence) publish.Evidence { return publish.Evidence(e) }
func publicEvidence(e publish.Evidence) PublicationEvidence { return PublicationEvidence(e) }
func publicObservation(o publish.Observation) PublicationReconciliationResult {
return PublicationReconciliationResult{Evidence: publicEvidence(o.Evidence), State: PublicationState(o.State), ObservedTagObject: o.ObservedTagObject, ObservedCommit: o.ObservedCommit}
}
func publicRecoveryError(err error, path string) error {
if err == nil {
return nil
}
var recovery *publish.RecoveryError
if errors.As(err, &recovery) {
recordPath := path
if recordPath == "" {
recordPath = recovery.RecordPath
}
return &PublicationRecoveryError{Kind: recovery.Kind, RecordPath: recordPath, MissingFields: append([]string(nil), recovery.MissingFields...), cause: recovery}
}
return &PublicationRecoveryError{Kind: "remote_observation", RecordPath: path, cause: err}
}

View File

@@ -0,0 +1,21 @@
package releasekit
import (
"errors"
"testing"
"go.maximumdirect.net/releasekit/internal/publish"
)
func TestPublicAttemptsRetainsPartialResultAndFailure(t *testing.T) {
failure := &publish.RecoveryError{Kind: "storage_enumeration"}
diagnostic := &publish.RecoveryError{Kind: "record_format", RecordPath: "broken.json"}
result, err := publicAttemptsResult(publish.AttemptsResult{
RecordDir: "records", Attempts: []publish.Attempt{{RecordPath: "good.json", Evidence: publish.Evidence{Tag: "v1.2.3"}}},
Diagnostics: []*publish.RecoveryError{diagnostic},
}, failure)
var typed *PublicationRecoveryError
if !errors.As(err, &typed) || typed.Kind != "storage_enumeration" || !errors.Is(err, failure) || len(result.Attempts) != 1 || result.Attempts[0].RecordPath != "good.json" || result.Attempts[0].Evidence.Tag != "v1.2.3" || len(result.Diagnostics) != 1 || !errors.Is(result.Diagnostics[0], diagnostic) {
t.Fatalf("facade lost partial results or cause: %#v, %v", result, err)
}
}

72
recovery_test.go Normal file
View File

@@ -0,0 +1,72 @@
package releasekit_test
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
releasekit "go.maximumdirect.net/releasekit"
)
func TestRecoveryFacadeRedactsSelectedPathAndRetainsRawCause(t *testing.T) {
path := "https://audit-user:audit-secret@example.invalid/attempt.json"
_, err := releasekit.LoadPublicationEvidence(path)
var recovery *releasekit.PublicationRecoveryError
var pathErr *os.PathError
if !errors.As(err, &recovery) || recovery.RecordPath != path || !errors.As(err, &pathErr) || pathErr.Path != path {
t.Fatalf("raw diagnostic context lost: %v", err)
}
if strings.Contains(err.Error(), "audit-secret") || strings.Contains(err.Error(), "audit-user") {
t.Fatalf("credentials in displayed error: %v", err)
}
}
func TestRecoveryFacadeLoadsPartialEvidenceAndPreservesCause(t *testing.T) {
path := filepath.Join(t.TempDir(), "attempt.json")
if err := os.WriteFile(path, []byte(`{"format_version":1,"evidence":{}}`), 0600); err != nil {
t.Fatal(err)
}
evidence, err := releasekit.LoadPublicationEvidence(path)
if err != nil || evidence != (releasekit.PublicationEvidence{}) {
t.Fatalf("LoadPublicationEvidence()=%#v, %v", evidence, err)
}
result, err := releasekit.ReconcilePublication(context.Background(), evidence)
var recovery *releasekit.PublicationRecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "missing_evidence" || result.State != releasekit.PublicationUnknown {
t.Fatalf("ReconcilePublication()=%#v, %v", result, err)
}
if len(recovery.MissingFields) != 2 || recovery.MissingFields[0] != "endpoint" || recovery.MissingFields[1] != "tag" {
t.Fatalf("missing fields=%v", recovery.MissingFields)
}
}
func TestRecoveryFacadeErrorUnwrapsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := releasekit.ReconcilePublication(ctx, releasekit.PublicationEvidence{Endpoint: "file:///unused", Tag: "v1.2.3"})
var recovery *releasekit.PublicationRecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) {
t.Fatalf("error=%v", err)
}
}
func TestListPublicationAttemptsReadsExplicitExternalStore(t *testing.T) {
directory := t.TempDir()
good := filepath.Join(directory, "attempt-a.json")
if err := os.WriteFile(good, []byte(`{"format_version":1,"evidence":{"tag":"v1.2.3"}}`), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "attempt-b.json"), []byte("{"), 0600); err != nil {
t.Fatal(err)
}
listed, err := releasekit.ListPublicationAttempts(context.Background(), releasekit.PublicationAttemptsRequest{RecordDir: directory})
if err != nil || listed.RecordDir != directory || len(listed.Attempts) != 1 || len(listed.Diagnostics) != 1 {
t.Fatalf("listing=%#v error=%v", listed, err)
}
if listed.Attempts[0].RecordPath != good || listed.Attempts[0].Evidence.Tag != "v1.2.3" || listed.Diagnostics[0].Kind != "record_format" {
t.Fatalf("listing=%#v", listed)
}
}