Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23203f235c | |||
| 37d6209aec | |||
| ee72566263 | |||
| e0019abe31 | |||
| 2a7bed87db | |||
| dfaa652098 | |||
| cd08a2892a | |||
| 16d1c072e3 | |||
| 363f7cd513 | |||
| cd274512e7 | |||
| 3b8bacf114 | |||
| 781341a0ba | |||
| 109596bc3b | |||
| 6fc140d8dc | |||
| 993866c1d7 | |||
| 19a14bfeb6 | |||
| 648c87bb8a | |||
| 04db23705a | |||
| 9a7b9b9301 | |||
| d2b1329095 | |||
| 0d19a5546f | |||
| da1839c6d2 | |||
| 957e75524c | |||
| 0ef135bfda | |||
| 3280f734ef | |||
| 599ad81bec | |||
| 6915a86132 | |||
| 4afdd9b826 | |||
| fb5947ea3a | |||
| 2a04938a96 | |||
| b11c6d1788 | |||
| 1ddeb2c6dc | |||
| 4dff8086ab | |||
| 447b42a1cd | |||
| 58656dea45 | |||
| 58c20fa863 | |||
| f73f0c1b6a | |||
| bff4ed8a8b | |||
| e0bf5ca57a | |||
| 691926d839 | |||
| 6fab30337a | |||
| e736166a39 | |||
| a2d75c462c | |||
| 31f9e48219 | |||
| 473670185c | |||
| c8fa165dc9 | |||
| 2f95753d9c | |||
| 9dc87ae996 | |||
| d9c97fd915 | |||
| 1d40fb7ac0 | |||
| 03ee83e8a5 | |||
| 9cb48a70b7 | |||
| 2b23574ece | |||
| 76676ee5a6 | |||
| 23b3a3d340 | |||
| ef47356541 | |||
| 0666b68ff6 | |||
| 6f2e62bea3 | |||
| aa57fee4de | |||
| 92c95ef031 | |||
| 595745c7b1 | |||
| 9c02ed769b | |||
| 94aedacf05 | |||
| 7e6e0cc7d1 | |||
| aecab00ff6 | |||
| a1dc705be7 | |||
| b5b3f09599 | |||
| 6f7e43b097 | |||
| 91f25c452c | |||
| 083bc83331 | |||
| 5e6db4de76 | |||
| b345bc0ec4 | |||
| 3d7a691338 | |||
| 3754242969 | |||
| 9545bb0bfc | |||
| 620c832477 | |||
| 812c24de9e | |||
| 1b39e71e81 | |||
| d884ad731d |
5
build.go
5
build.go
@@ -145,10 +145,7 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
|
|||||||
func internalBuildResult(result BuildResult) *internalbuild.Result {
|
func internalBuildResult(result BuildResult) *internalbuild.Result {
|
||||||
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
|
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
|
||||||
settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.Targets)
|
settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.Targets)
|
||||||
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler}
|
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, OutputDir: result.OutputDir, Compiler: result.Compiler}
|
||||||
for _, target := range settings.Targets {
|
|
||||||
converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
|
|
||||||
}
|
|
||||||
for _, artifact := range result.Artifacts {
|
for _, artifact := range result.Artifacts {
|
||||||
converted.Artifacts = append(converted.Artifacts, internalbuild.Artifact{Path: artifact.Path, Filename: artifact.Filename, Target: internalbuild.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256})
|
converted.Artifacts = append(converted.Artifacts, internalbuild.Artifact{Path: artifact.Path, Filename: artifact.Filename, Target: internalbuild.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,41 @@ import (
|
|||||||
releasekit "go.maximumdirect.net/releasekit"
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestBuildReplacementDerivedSourceIsNotEligibleForDelivery(t *testing.T) {
|
||||||
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
|
original := buildGit(t, repository, "rev-parse", "HEAD")
|
||||||
|
mainPath := filepath.Join(repository, "cmd/app/main.go")
|
||||||
|
main, err := os.ReadFile(mainPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeBuildFixture(t, repository, "cmd/app/main.go", string(main)+"\n// replacement source\n")
|
||||||
|
buildGit(t, repository, "add", "cmd/app/main.go")
|
||||||
|
buildGit(t, repository, "commit", "-m", "Replacement source")
|
||||||
|
replacement := buildGit(t, repository, "rev-parse", "HEAD")
|
||||||
|
buildGit(t, repository, "reset", "--hard", original)
|
||||||
|
buildGit(t, repository, "replace", original, replacement)
|
||||||
|
buildGit(t, repository, "reset", "--hard", original)
|
||||||
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||||
|
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
|
||||||
|
})
|
||||||
|
if err != nil || result.SourceVerified || len(result.Artifacts) != 1 || result.CandidateCommit != original {
|
||||||
|
t.Fatalf("replacement build = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
buildGit(t, repository, "replace", "-d", original)
|
||||||
|
buildGit(t, repository, "reset", "--hard", original)
|
||||||
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
|
defer server.Close()
|
||||||
|
delivery, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||||
|
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &result,
|
||||||
|
})
|
||||||
|
if err == nil || delivery.State != releasekit.DeliveryNotAttempted || remote.snapshot().writes != 0 {
|
||||||
|
t.Fatalf("delivery of replacement build = %#v, error = %v", delivery, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
||||||
spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}}
|
spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}}
|
||||||
invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
@@ -486,6 +521,61 @@ func TestBuildReportsMissingMainPackage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildRetainsOnlyHashedArtifactsAfterReadFailure(t *testing.T) {
|
||||||
|
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
||||||
|
realGo, err := exec.LookPath("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
count := filepath.Join(t.TempDir(), "count")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
if [ "$1" != build ]; then exec "$RELEASEKIT_REAL_GO" "$@"; fi
|
||||||
|
output=
|
||||||
|
previous=
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$previous" = -o ]; then output=$arg; fi
|
||||||
|
previous=$arg
|
||||||
|
done
|
||||||
|
"$RELEASEKIT_REAL_GO" "$@" || exit $?
|
||||||
|
if [ -f "$RELEASEKIT_BUILD_COUNT" ]; then
|
||||||
|
rm "$output"
|
||||||
|
mkdir "$output"
|
||||||
|
else
|
||||||
|
: > "$RELEASEKIT_BUILD_COUNT"
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("RELEASEKIT_REAL_GO", realGo)
|
||||||
|
t.Setenv("RELEASEKIT_BUILD_COUNT", count)
|
||||||
|
secondArch := "arm64"
|
||||||
|
if runtime.GOARCH == secondArch {
|
||||||
|
secondArch = "amd64"
|
||||||
|
}
|
||||||
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||||
|
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}, {OS: runtime.GOOS, Arch: secondArch}}},
|
||||||
|
})
|
||||||
|
var pathErr *os.PathError
|
||||||
|
if !errors.As(err, &pathErr) || !strings.Contains(err.Error(), "hash build output") || len(result.Artifacts) != 1 || len(result.Targets) != 2 || result.MainPackage != "./cmd/app" || result.BinaryName != "fixture" || result.CandidateCommit == "" || result.SourceVerified {
|
||||||
|
t.Fatalf("partial build = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
contents, readErr := os.ReadFile(result.Artifacts[0].Path)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(contents)
|
||||||
|
if result.Artifacts[0].SHA256 != hex.EncodeToString(digest[:]) {
|
||||||
|
t.Fatalf("completed artifact digest = %q", result.Artifacts[0].SHA256)
|
||||||
|
}
|
||||||
|
if info, statErr := os.Stat(pathErr.Path); statErr != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("failed output was not retained: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) {
|
func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) {
|
||||||
repository := newBuildConsumer(t, `package main
|
repository := newBuildConsumer(t, `package main
|
||||||
import (
|
import (
|
||||||
|
|||||||
5
check.go
5
check.go
@@ -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.
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
520
cmd/releasekit/publication_handoff_test.go
Normal file
520
cmd/releasekit/publication_handoff_test.go
Normal 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
|
||||||
|
}
|
||||||
351
cmd/releasekit/publication_recovery_workflow_test.go
Normal file
351
cmd/releasekit/publication_recovery_workflow_test.go
Normal 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
259
cmd/releasekit/recovery.go
Normal 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)
|
||||||
|
}
|
||||||
487
cmd/releasekit/recovery_test.go
Normal file
487
cmd/releasekit/recovery_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ type DeliveryRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryResult reports hosted content and the state reached by this
|
// DeliveryResult reports hosted content and the state reached by this
|
||||||
// invocation. A partial result may accompany an error. The asset slices are
|
// invocation. A result may accompany an error, including Complete when hosted
|
||||||
|
// delivery succeeded but local managed-build cleanup failed. The asset slices are
|
||||||
// current observations: an asset appears in at most one slice, and early
|
// current observations: an asset appears in at most one slice, and early
|
||||||
// failures may leave some declared assets unreported.
|
// failures may leave some declared assets unreported.
|
||||||
type DeliveryResult struct {
|
type DeliveryResult struct {
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
package releasekit_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
releasekit "go.maximumdirect.net/releasekit"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDeliverReadsLocalBinaryOnceForVerificationAndOnceForUpload(t *testing.T) {
|
|
||||||
repository, build, expected := newDeliveryBenchmarkFixture(t)
|
|
||||||
watcher, err := syscall.InotifyInit1(syscall.IN_NONBLOCK | syscall.IN_CLOEXEC)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer syscall.Close(watcher)
|
|
||||||
// Close events separate successive opens so inotify cannot coalesce them.
|
|
||||||
if _, err := syscall.InotifyAddWatch(watcher, build.Artifacts[0].Path, syscall.IN_OPEN|syscall.IN_CLOSE_NOWRITE); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
state := &deliveryBenchmarkServer{commit: build.CandidateCommit, assets: expected}
|
|
||||||
state.reset(false)
|
|
||||||
server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
|
|
||||||
state.baseURL = "http://" + server.Listener.Addr().String()
|
|
||||||
server.Start()
|
|
||||||
defer server.Close()
|
|
||||||
result, err := releasekit.Deliver(t.Context(), releasekit.DeliveryRequest{
|
|
||||||
RepositoryDir: repository, Product: "Sample App", Version: build.Version,
|
|
||||||
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &build,
|
|
||||||
})
|
|
||||||
if err != nil || result.State != releasekit.DeliveryComplete || len(result.Uploaded) != len(expected) {
|
|
||||||
t.Fatalf("delivery = %#v, error = %v", result, err)
|
|
||||||
}
|
|
||||||
state.verifyUploads(t)
|
|
||||||
opens := 0
|
|
||||||
var events [256]byte
|
|
||||||
for {
|
|
||||||
n, err := syscall.Read(watcher, events[:])
|
|
||||||
if errors.Is(err, syscall.EAGAIN) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if errors.Is(err, syscall.EINTR) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for offset := 0; offset < n; {
|
|
||||||
mask := binary.NativeEndian.Uint32(events[offset+4:])
|
|
||||||
if mask&syscall.IN_Q_OVERFLOW != 0 {
|
|
||||||
t.Fatal("artifact event queue overflowed")
|
|
||||||
}
|
|
||||||
if mask&syscall.IN_OPEN != 0 {
|
|
||||||
opens++
|
|
||||||
}
|
|
||||||
nameLength := binary.NativeEndian.Uint32(events[offset+12:])
|
|
||||||
offset += syscall.SizeofInotifyEvent + int(nameLength)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if opens != 2 {
|
|
||||||
t.Fatalf("local binary opened %d times; want one verification read and one upload read", opens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -307,6 +307,34 @@ func TestDeliverPrebuiltArtifactsRemainCallerOwned(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeliverRejectsOversizedExistingPrebuiltAsset(t *testing.T) {
|
||||||
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
|
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||||
|
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(prebuilt.Artifacts[0].Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
|
defer server.Close()
|
||||||
|
remote.update(func(data *facadeDeliveryData) {
|
||||||
|
data.pages["v1.2.3"] = true
|
||||||
|
data.assets[prebuilt.Artifacts[0].Filename] = append(content, 'x')
|
||||||
|
})
|
||||||
|
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||||
|
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &prebuilt,
|
||||||
|
})
|
||||||
|
if err == nil || result.State != releasekit.DeliveryConflict || len(result.Conflicting) != 1 || result.Conflicting[0].Filename != prebuilt.Artifacts[0].Filename || remote.snapshot().writes != 0 {
|
||||||
|
t.Fatalf("delivery = %#v, error = %v, writes = %d", result, err, remote.snapshot().writes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) {
|
func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) {
|
||||||
repository := newTaggedDeliveryConsumer(t)
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
@@ -402,6 +430,29 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeliverUsesOriginalNoteWithBlobReplacement(t *testing.T) {
|
||||||
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
|
original := buildGit(t, repository, "rev-parse", "HEAD:docs/releases/v1.2.3.md")
|
||||||
|
replacementPath := filepath.Join(t.TempDir(), "note.md")
|
||||||
|
if err := os.WriteFile(replacementPath, []byte("# Fixture v1.2.3\n\nReplacement note.\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replacement := buildGit(t, repository, "hash-object", "-w", replacementPath)
|
||||||
|
buildGit(t, repository, "replace", original, replacement)
|
||||||
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
|
defer server.Close()
|
||||||
|
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||||
|
Destination: facadeDeliveryDestination(server.URL),
|
||||||
|
})
|
||||||
|
if err != nil || result.State != releasekit.DeliveryComplete {
|
||||||
|
t.Fatalf("delivery = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if got := remote.snapshot().createdBody; got != "# Fixture v1.2.3\n\nDelivery fixture.\n" {
|
||||||
|
t.Fatalf("delivered note = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
|
func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
|
||||||
repository := newTwoModuleDeliveryConsumer(t)
|
repository := newTwoModuleDeliveryConsumer(t)
|
||||||
server, remote := newFacadeDeliveryServer(t, repository)
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
@@ -817,6 +868,7 @@ type facadeDeliveryData struct {
|
|||||||
tag string
|
tag string
|
||||||
version string
|
version string
|
||||||
noteBody string
|
noteBody string
|
||||||
|
createdBody string
|
||||||
pages map[string]bool
|
pages map[string]bool
|
||||||
requests int
|
requests int
|
||||||
writes int
|
writes int
|
||||||
@@ -876,6 +928,7 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
|||||||
if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version {
|
if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version {
|
||||||
data.t.Errorf("release payload = %#v", payload)
|
data.t.Errorf("release payload = %#v", payload)
|
||||||
}
|
}
|
||||||
|
data.createdBody = payload.Body
|
||||||
data.pages[data.tag] = true
|
data.pages[data.tag] = true
|
||||||
state.writeRelease(w)
|
state.writeRelease(w)
|
||||||
case "/api/v1/repos/owner/example/releases/10/assets":
|
case "/api/v1/repos/owner/example/releases/10/assets":
|
||||||
|
|||||||
84
docs/cli.md
84
docs/cli.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ module. The default output directory is `<module>/dist`; a relative `OutputDir`
|
|||||||
is resolved from the module, while an absolute path is used as supplied.
|
is resolved from the module, while an absolute path is used as supplied.
|
||||||
Selected artifact filenames are regenerated. Other files in the output
|
Selected artifact filenames are regenerated. Other files in the output
|
||||||
directory are left in place. A failed later build or native version check
|
directory are left in place. A failed later build or native version check
|
||||||
returns the binaries already generated in `BuildResult.Artifacts`.
|
returns completed, hashed binaries in `BuildResult.Artifacts`. If hashing a
|
||||||
|
newly built file fails, the error identifies its path and earlier completed
|
||||||
|
artifacts remain in the result.
|
||||||
|
|
||||||
Builds use the available compatible Go toolchain selected locally; ReleaseKit
|
Builds use the available compatible Go toolchain selected locally; ReleaseKit
|
||||||
does not download a different toolchain automatically. It disables workspace
|
does not download a different toolchain automatically. It disables workspace
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ an upload was confirmed during this invocation; `Pending` means known missing
|
|||||||
work; `Conflicting` means different hosted content was found; `Uncertain` means
|
work; `Conflicting` means different hosted content was found; `Uncertain` means
|
||||||
the remote state could not be resolved.
|
the remote state could not be resolved.
|
||||||
|
|
||||||
|
An error can accompany `complete` when hosted delivery succeeded but removal
|
||||||
|
of managed build files failed. Address the reported local cleanup failure;
|
||||||
|
the completed hosted delivery does not need to be repeated.
|
||||||
|
|
||||||
Fix local or preflight failures before retrying. For partial delivery, fix the
|
Fix local or preflight failures before retrying. For partial delivery, fix the
|
||||||
reported cause and retry the same version. Resolve an unknown remote outcome
|
reported cause and retry the same version. Resolve an unknown remote outcome
|
||||||
before retrying. Investigate conflicts and use a new version if the source or
|
before retrying. Investigate conflicts and use a new version if the source or
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
123
docs/integrations/publication-records.md
Normal file
123
docs/integrations/publication-records.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
106
docs/releases/v0.7.0.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# ReleaseKit v0.7.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ReleaseKit improves source-publication integrity, cancellation, credential-safe
|
||||||
|
diagnostics, partial-result reporting, and temporary-file cleanup. It also bounds
|
||||||
|
retained Go failure diagnostics and remote attachment comparison work while
|
||||||
|
preserving the existing public facades and configuration formats.
|
||||||
|
|
||||||
|
## Publication and Git operations
|
||||||
|
|
||||||
|
- Exact tag pushes disable submodule recursion, correcting inherited
|
||||||
|
`push.recurseSubmodules=only` settings that could report success without
|
||||||
|
publishing the release tag.
|
||||||
|
- ReleaseKit-owned Git inspection and preparation use original objects when
|
||||||
|
replacement refs exist. Committed module identity, release notes, validation
|
||||||
|
inputs, and build source eligibility use that consistent view. Ordinary dirty
|
||||||
|
builds remain supported, with their source eligibility reported separately.
|
||||||
|
- A verified local tag object remains available in `PublicationResult.TagObject`
|
||||||
|
when later inspection fails. Errors identify a possibly retained local ref
|
||||||
|
when its object ID could not be established. Failed verification stops before
|
||||||
|
a push.
|
||||||
|
- HTTP(S) userinfo is masked in owned Git diagnostics and CLI output, including
|
||||||
|
literal apostrophes in credentials and failed CI checkout preparation. Native
|
||||||
|
Git authentication, exact endpoint comparisons, and the raw captured
|
||||||
|
`PublicationResult.Endpoint` value retain their existing behavior.
|
||||||
|
- Child environment overlays preserve the working-directory-derived `PWD` on
|
||||||
|
POSIX systems. Native Git hooks now receive the expected directory environment
|
||||||
|
when ReleaseKit is invoked from another checkout or module subdirectory.
|
||||||
|
|
||||||
|
## Cancellation and diagnostic retention
|
||||||
|
|
||||||
|
- Buffered subprocess output has bounded I/O teardown after cancellation or
|
||||||
|
child exit, including when descendants retain inherited output descriptors.
|
||||||
|
Piped Git-reader cancellation closes owned pipes and joins child cleanup.
|
||||||
|
Cancellation remains recognizable through `errors.Is`; ordinary execution
|
||||||
|
failures preserve their underlying causes.
|
||||||
|
- A queued blob read rechecks cancellation after acquiring the reader mutex,
|
||||||
|
preserving a healthy batch session for later reads.
|
||||||
|
- Status-only Go commands and dependency preparation retain a 1 MiB diagnostic
|
||||||
|
tail. Failed commands identify omitted earlier output. Existing streaming
|
||||||
|
output continues to be forwarded in full, and commands requiring returned
|
||||||
|
output keep their complete capture.
|
||||||
|
|
||||||
|
## Builds and hosted delivery
|
||||||
|
|
||||||
|
- Completed build artifacts are reported only after hashing succeeds. A hashing
|
||||||
|
failure identifies the affected output and preserves earlier completed
|
||||||
|
artifacts and their digests.
|
||||||
|
- Temporary allocation and cleanup failures retain their paths and underlying
|
||||||
|
causes, including candidate builds, native version checks, delivery builds,
|
||||||
|
tag-message files, and build receipts.
|
||||||
|
- A managed-build cleanup failure after confirmed hosted delivery returns an
|
||||||
|
error alongside `DeliveryComplete` and the confirmed asset observations. The
|
||||||
|
CLI reports completed remote work and the local cleanup failure, returning
|
||||||
|
operational status 1.
|
||||||
|
- Gitea attachment downloads follow at most ten redirects, retaining cycle
|
||||||
|
detection and per-origin authorization handling. Limit exhaustion is
|
||||||
|
unverifiable content.
|
||||||
|
- Remote attachment comparison reads at most the locally verified expected size
|
||||||
|
plus one byte. Shorter or longer content conflicts; equal-length content still
|
||||||
|
requires the expected digest. Read, close, and cancellation errors remain
|
||||||
|
inspection failures, with existing attempted-write reconciliation preserved.
|
||||||
|
|
||||||
|
Shared Git observations, validation sequencing, artifact declaration matching,
|
||||||
|
and CLI workflows were consolidated internally. Consumer-facing types continue
|
||||||
|
to belong to the root `releasekit` and runtime `buildinfo` facades.
|
||||||
|
|
||||||
|
## Compatibility and upgrade guidance
|
||||||
|
|
||||||
|
The module identifier remains `go.maximumdirect.net/releasekit`, and the Go
|
||||||
|
consumer minimum remains 1.26.0. Existing v0.6.0 API calls, profile fields, and
|
||||||
|
build receipt formats remain supported. This minor release changes operational
|
||||||
|
behavior through the diagnostic and redirect budgets and cleanup-error reporting
|
||||||
|
described above.
|
||||||
|
|
||||||
|
When adopting v0.7.0:
|
||||||
|
|
||||||
|
1. Update library and CLI dependencies to v0.7.0 using the existing import paths
|
||||||
|
and profiles.
|
||||||
|
2. Handle a delivery error together with `DeliveryResult.State`. For
|
||||||
|
`DeliveryComplete`, address the reported local cleanup path; the confirmed
|
||||||
|
hosted delivery is already complete. Continue to resolve partial, conflicting,
|
||||||
|
and unknown outcomes through their existing recovery guidance.
|
||||||
|
3. Retain candidate validation's streamed `Output` when complete test diagnostics
|
||||||
|
are needed. Status-only failure text now contains the retained tail and an
|
||||||
|
omission notice when applicable.
|
||||||
|
4. When using Git replacement refs, expect release evidence to be evaluated
|
||||||
|
against original committed objects. A build of dirty or replacement-derived
|
||||||
|
working content can succeed while remaining ineligible for binary delivery.
|
||||||
|
5. Inspect the reported local tag ref after a tag-creation or verification
|
||||||
|
failure, including when `TagObject` is empty. Continue to treat the raw
|
||||||
|
`PublicationResult.Endpoint` as sensitive when handling it in application
|
||||||
|
output or logs.
|
||||||
|
|
||||||
|
See the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/publish.md),
|
||||||
|
[build guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/build.md),
|
||||||
|
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/delivery.md),
|
||||||
|
[CLI contract](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/cli.md),
|
||||||
|
and [Gitea integration guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/integrations/gitea.md)
|
||||||
|
for current behavior. Source publication and hosted delivery retain their
|
||||||
|
separate outcomes under the [maintainer procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/oss/attribution.md)
|
||||||
54
docs/releases/v0.8.0.md
Normal file
54
docs/releases/v0.8.0.md
Normal 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)
|
||||||
67
docs/roadmap/deferred-resources.md
Normal file
67
docs/roadmap/deferred-resources.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Deferred resource work
|
||||||
|
|
||||||
|
These two proposals remain deferred after the completed audit remediation for
|
||||||
|
v0.7.0. Their assessments are complete; production limits and exclusion indexing
|
||||||
|
await a future decision supported by consumer needs. The assessment evidence
|
||||||
|
was recorded in commit `9dc87ae`.
|
||||||
|
|
||||||
|
## Forge JSON response budgets
|
||||||
|
|
||||||
|
**Status:** Deferred; local sizing assessment complete.
|
||||||
|
|
||||||
|
Forge JSON decoding can consume memory proportional to response size. Release
|
||||||
|
notes and unpaginated attachment lists have no established supported maximum.
|
||||||
|
A future proposal should establish an appropriate response envelope before
|
||||||
|
selecting byte/time budgets or configuration. Any limit must retain context
|
||||||
|
cancellation, useful error classification, and uncertain-write reconciliation:
|
||||||
|
a rejected response does not establish that a create or upload failed remotely.
|
||||||
|
|
||||||
|
The [Gitea fixtures](../../internal/gitea/backend_test.go) construct repository,
|
||||||
|
release, tag, and attachment responses. Representative encoded fixtures measured
|
||||||
|
81 bytes for repository identity, 136 with permissions, 125 for a sparse release,
|
||||||
|
144 for a populated release, 73 for a one-item attachment list, and 62 for an
|
||||||
|
upload response, including the encoder's newline. Four literal release-ID
|
||||||
|
fixtures measured 21, 28, 29, and 29 bytes without a newline. Generated URLs,
|
||||||
|
notes, and lists vary with fixture inputs.
|
||||||
|
|
||||||
|
The checked-in v0.1.0–v0.6.0 release notes measured 1,235–7,899 bytes during the
|
||||||
|
assessment. These observations describe local examples and establish no
|
||||||
|
deployment or protocol maximum. Production response caps remain deferred.
|
||||||
|
|
||||||
|
## File-size exclusion indexing
|
||||||
|
|
||||||
|
**Status:** Deferred; benchmark assessment complete.
|
||||||
|
|
||||||
|
The current [exclusion scan](../../internal/validation/file_size.go) compares
|
||||||
|
each selected file with the normalized exclusions until a match is found.
|
||||||
|
The adoption decision weighs measured speedups against allocation and
|
||||||
|
path-walking costs for consumer workloads. Preserve literal component boundaries,
|
||||||
|
module-relative normalization, shared inputs, and the first oversized-file result.
|
||||||
|
|
||||||
|
The [benchmark-only prototype](../../internal/validation/file_size_benchmark_test.go)
|
||||||
|
compares the scan with a normalized set and ancestor lookups. It covers 1,000
|
||||||
|
and 10,000 paths, 1/10/100/1,000 exclusions, shallow/deep paths, early matches,
|
||||||
|
and nonmatches. Each measured operation includes normalization and one index
|
||||||
|
construction where applicable; fixture strings are prepared outside timing.
|
||||||
|
|
||||||
|
On Linux/amd64, Go 1.26.8, Intel Core i5-7260U, 64 combinations ran with three
|
||||||
|
samples each using:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go test ./internal/validation \
|
||||||
|
-run '^$' -bench '^BenchmarkFileSizeExclusions$' -benchtime=100ms -count=3
|
||||||
|
```
|
||||||
|
|
||||||
|
Representative median times and allocations were:
|
||||||
|
|
||||||
|
| Paths / exclusions / case | Scan | Index |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1,000 / 1 / shallow early match | 24.5 µs, 16 B, 1 allocation | 44.0 µs, 16 B, 1 allocation |
|
||||||
|
| 1,000 / 1 / deep nonmatch | 25.7 µs, 16 B, 1 allocation | 119.0 µs, 16 B, 1 allocation |
|
||||||
|
| 10,000 / 1,000 / shallow early match | 270 µs, 16,384 B, 1 allocation | 564 µs, 70,992 B, 6 allocations |
|
||||||
|
| 10,000 / 1,000 / shallow nonmatch | 227.0 ms, 16,384 B, 1 allocation | 521 µs, 70,992 B, 6 allocations |
|
||||||
|
| 10,000 / 1,000 / deep nonmatch | 233.4 ms, 16,384 B, 1 allocation | 1.36 ms, 70,992 B, 6 allocations |
|
||||||
|
|
||||||
|
The prototype was slower for small lists and early matches, and faster for
|
||||||
|
large nonmatching lists with higher allocation costs. Production matching
|
||||||
|
remains unchanged pending an adoption decision.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
|
|||||||
@@ -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}" \
|
||||||
|
|||||||
@@ -90,3 +90,30 @@ func ExpectedBuild(binaryName, version string, targets []Target) []Declaration {
|
|||||||
}
|
}
|
||||||
return declarations
|
return declarations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateDeclarations matches recorded artifact identities to selected targets.
|
||||||
|
func ValidateDeclarations(binaryName, version string, targets []Target, recorded []Declaration) error {
|
||||||
|
if len(recorded) != len(targets) {
|
||||||
|
return fmt.Errorf("artifact and target counts do not match")
|
||||||
|
}
|
||||||
|
want := make(map[Target]string, len(targets))
|
||||||
|
for _, declaration := range ExpectedBuild(binaryName, version, targets) {
|
||||||
|
target := declaration.Target
|
||||||
|
if !ValidComponent(target.OS) || !ValidComponent(target.Arch) {
|
||||||
|
return fmt.Errorf("invalid target %q/%q", target.OS, target.Arch)
|
||||||
|
}
|
||||||
|
if _, exists := want[target]; exists {
|
||||||
|
return fmt.Errorf("repeats target %q/%q", target.OS, target.Arch)
|
||||||
|
}
|
||||||
|
want[target] = declaration.Filename
|
||||||
|
}
|
||||||
|
seen := make(map[Target]bool, len(recorded))
|
||||||
|
for _, declaration := range recorded {
|
||||||
|
name, ok := want[declaration.Target]
|
||||||
|
if !ok || seen[declaration.Target] || declaration.Filename != name {
|
||||||
|
return fmt.Errorf("artifact %q does not match a selected target and its declared name", declaration.Filename)
|
||||||
|
}
|
||||||
|
seen[declaration.Target] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,3 +56,31 @@ func TestValidComponentPreservesBuildAndReceiptRules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateDeclarationsMatchesTargetsAndNames(t *testing.T) {
|
||||||
|
linux := Target{OS: "linux", Arch: "amd64"}
|
||||||
|
windows := Target{OS: "windows", Arch: "arm64"}
|
||||||
|
first := Declaration{Target: linux, Filename: "app-v1.2.3-linux-amd64"}
|
||||||
|
second := Declaration{Target: windows, Filename: "app-v1.2.3-windows-arm64.exe"}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
targets []Target
|
||||||
|
recorded []Declaration
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"reordered", []Target{linux, windows}, []Declaration{second, first}, true},
|
||||||
|
{"count", []Target{linux, windows}, []Declaration{first}, false},
|
||||||
|
{"duplicate target", []Target{linux, linux}, []Declaration{first, first}, false},
|
||||||
|
{"duplicate artifact", []Target{linux, windows}, []Declaration{first, first}, false},
|
||||||
|
{"incorrect name", []Target{linux}, []Declaration{{Target: linux, Filename: "other"}}, false},
|
||||||
|
{"unknown target", []Target{linux}, []Declaration{second}, false},
|
||||||
|
{"unsafe target", []Target{{OS: "linux/other", Arch: "amd64"}}, []Declaration{{Target: linux, Filename: first.Filename}}, false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
err := ValidateDeclarations("app", "v1.2.3", test.targets, test.recorded)
|
||||||
|
if (err == nil) != test.valid {
|
||||||
|
t.Fatalf("ValidateDeclarations() error = %v, valid = %t", err, test.valid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package build
|
package build
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -9,13 +10,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
"go.maximumdirect.net/releasekit/internal/tempdir"
|
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
@@ -48,27 +50,25 @@ type Result struct {
|
|||||||
SourceVerified bool
|
SourceVerified bool
|
||||||
Settings artifact.Settings
|
Settings artifact.Settings
|
||||||
Version string
|
Version string
|
||||||
MainPackage string
|
|
||||||
BinaryName string
|
|
||||||
OutputDir string
|
OutputDir string
|
||||||
Targets []Target
|
|
||||||
Artifacts []Artifact
|
Artifacts []Artifact
|
||||||
Compiler string
|
Compiler string
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||||
|
return buildResolved(ctx, request, os.RemoveAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildResolved(ctx context.Context, request Request, remove func(string) error) (result Result, resultErr error) {
|
||||||
identity := request.Identity
|
identity := request.Identity
|
||||||
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
|
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
|
||||||
result := Result{
|
result = Result{
|
||||||
RepositoryDir: identity.RepositoryDir,
|
RepositoryDir: identity.RepositoryDir,
|
||||||
ModuleDir: identity.ModuleDir,
|
ModuleDir: identity.ModuleDir,
|
||||||
ModulePath: identity.ModulePath,
|
ModulePath: identity.ModulePath,
|
||||||
Tag: identity.Tag,
|
Tag: identity.Tag,
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
Version: identity.Version,
|
Version: identity.Version,
|
||||||
MainPackage: settings.MainPackage,
|
|
||||||
BinaryName: settings.BinaryName,
|
|
||||||
Targets: settings.Targets,
|
|
||||||
}
|
}
|
||||||
if !versioncontract.ValidStableVersion(identity.Version) {
|
if !versioncontract.ValidStableVersion(identity.Version) {
|
||||||
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version)
|
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version)
|
||||||
@@ -76,14 +76,14 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
|
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
|
||||||
return result, errors.New("build requires a resolved module identity")
|
return result, errors.New("build requires a resolved module identity")
|
||||||
}
|
}
|
||||||
if len(result.Targets) != 0 {
|
if len(settings.Targets) != 0 {
|
||||||
if settings.MainPackage == "" {
|
if settings.MainPackage == "" {
|
||||||
return result, errors.New("build requires a main package when targets are selected")
|
return result, errors.New("build requires a main package when targets are selected")
|
||||||
}
|
}
|
||||||
if !artifact.ValidComponent(settings.BinaryName) {
|
if !artifact.ValidComponent(settings.BinaryName) {
|
||||||
return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName)
|
return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName)
|
||||||
}
|
}
|
||||||
for _, target := range result.Targets {
|
for _, target := range settings.Targets {
|
||||||
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
||||||
return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch)
|
return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch)
|
||||||
}
|
}
|
||||||
@@ -102,17 +102,17 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, fmt.Errorf("resolve module directory: %w", err)
|
return result, fmt.Errorf("resolve module directory: %w", err)
|
||||||
}
|
}
|
||||||
result.ModuleDir = moduleDir
|
result.ModuleDir = moduleDir
|
||||||
commit, err := gitCommit(ctx, repositoryDir)
|
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, fmt.Errorf("read source commit: %w", err)
|
||||||
}
|
}
|
||||||
result.CandidateCommit = commit
|
result.CandidateCommit = commit
|
||||||
cleanBefore, err := cleanSource(ctx, repositoryDir)
|
statusBefore, err := git.WorktreeStatus(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, fmt.Errorf("inspect build source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Targets) == 0 {
|
if len(settings.Targets) == 0 {
|
||||||
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
|
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
goHost := goTool.HostTarget()
|
goHost := goTool.HostTarget()
|
||||||
host := Target{OS: goHost.OS, Arch: goHost.Arch}
|
host := Target{OS: goHost.OS, Arch: goHost.Arch}
|
||||||
|
|
||||||
for index, target := range result.Targets {
|
for index, target := range settings.Targets {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return result, fmt.Errorf("build canceled: %w", err)
|
return result, fmt.Errorf("build canceled: %w", err)
|
||||||
}
|
}
|
||||||
@@ -156,10 +156,10 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
|
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
|
||||||
}
|
}
|
||||||
artifact := Artifact{Path: path, Filename: filename, Target: target}
|
artifact := Artifact{Path: path, Filename: filename, Target: target}
|
||||||
result.Artifacts = append(result.Artifacts, artifact)
|
if err := hashArtifact(&artifact); err != nil {
|
||||||
if err := hashArtifact(&result.Artifacts[len(result.Artifacts)-1]); err != nil {
|
|
||||||
return result, fmt.Errorf("hash build output %q: %w", path, err)
|
return result, fmt.Errorf("hash build output %q: %w", path, err)
|
||||||
}
|
}
|
||||||
|
result.Artifacts = append(result.Artifacts, artifact)
|
||||||
}
|
}
|
||||||
|
|
||||||
var smokePath string
|
var smokePath string
|
||||||
@@ -174,7 +174,11 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("create native smoke directory: %w", err)
|
return result, fmt.Errorf("create native smoke directory: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporaryDir)
|
defer func() {
|
||||||
|
if err := remove(temporaryDir); err != nil {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("remove native smoke directory %q: %w", temporaryDir, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
smokePath = filepath.Join(temporaryDir, settings.BinaryName)
|
smokePath = filepath.Join(temporaryDir, settings.BinaryName)
|
||||||
if host.OS == "windows" {
|
if host.OS == "windows" {
|
||||||
smokePath += ".exe"
|
smokePath += ".exe"
|
||||||
@@ -192,30 +196,18 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.Version); err != nil {
|
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.Version); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
commitAfter, err := gitCommit(ctx, repositoryDir)
|
commitAfter, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, fmt.Errorf("read source commit: %w", err)
|
||||||
}
|
}
|
||||||
cleanAfter, err := cleanSource(ctx, repositoryDir)
|
statusAfter, err := git.WorktreeStatus(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, fmt.Errorf("inspect build source: %w", err)
|
||||||
}
|
}
|
||||||
result.SourceVerified = cleanBefore && cleanAfter && commitAfter == commit
|
result.SourceVerified = statusBefore == "" && statusAfter == "" && commitAfter == commit
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
|
|
||||||
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
|
|
||||||
output, err := command.Output()
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return "", fmt.Errorf("read source commit: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("read source commit: %w", err)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(output)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTarget(ctx context.Context, goTool *gotool.Tool, moduleDir, mainPackage, outputPath, version string, target Target) error {
|
func buildTarget(ctx context.Context, goTool *gotool.Tool, moduleDir, mainPackage, outputPath, version string, target Target) error {
|
||||||
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
||||||
err := goTool.Run(ctx, moduleDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
err := goTool.Run(ctx, moduleDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
||||||
@@ -238,29 +230,14 @@ func hashArtifact(artifact *Artifact) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
|
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
|
||||||
command := exec.CommandContext(ctx, binaryPath, "version")
|
var output bytes.Buffer
|
||||||
output, err := command.CombinedOutput()
|
err := process.Run(ctx, process.Invocation{Args: []string{binaryPath, "version"}, Stdout: &output, Stderr: &output})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(output.String()))
|
||||||
return fmt.Errorf("run native version check: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(string(output)))
|
|
||||||
}
|
}
|
||||||
want := binaryName + " " + version + "\n"
|
want := binaryName + " " + version + "\n"
|
||||||
if string(output) != want {
|
if output.String() != want {
|
||||||
return fmt.Errorf("native version output = %q, want %q", output, want)
|
return fmt.Errorf("native version output = %q, want %q", output.String(), want)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanSource(ctx context.Context, repositoryDir string) (bool, error) {
|
|
||||||
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "status", "--porcelain", "--untracked-files=all")
|
|
||||||
output, err := command.Output()
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return false, fmt.Errorf("inspect build source: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
return false, fmt.Errorf("inspect build source: %w", err)
|
|
||||||
}
|
|
||||||
return len(output) == 0, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package build
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,12 +15,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
||||||
|
return candidateCallback(spec, output, os.RemoveAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateCallback(spec *Spec, output io.Writer, remove func(string) error) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
||||||
if spec == nil || len(spec.Targets) == 0 {
|
if spec == nil || len(spec.Targets) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
selected := *spec
|
selected := *spec
|
||||||
selected.Targets = append([]Target(nil), spec.Targets...)
|
selected.Targets = append([]Target(nil), spec.Targets...)
|
||||||
return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) error {
|
return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) (resultErr error) {
|
||||||
moduleSubdir, err := filepath.Rel(captured.RepositoryDir, captured.ModuleDir)
|
moduleSubdir, err := filepath.Rel(captured.RepositoryDir, captured.ModuleDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve captured module path: %w", err)
|
return fmt.Errorf("resolve captured module path: %w", err)
|
||||||
@@ -39,7 +44,11 @@ func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create temporary candidate build output: %w", err)
|
return fmt.Errorf("create temporary candidate build output: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporaryDir)
|
defer func() {
|
||||||
|
if err := remove(temporaryDir); err != nil {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("remove candidate build directory %q: %w", temporaryDir, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
_, err = BuildResolved(ctx, Request{
|
_, err = BuildResolved(ctx, Request{
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
Spec: selected,
|
Spec: selected,
|
||||||
|
|||||||
124
internal/build/cleanup_test.go
Normal file
124
internal/build/cleanup_test.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cleanupFixture(t *testing.T, output string) releaseidentity.Identity {
|
||||||
|
t.Helper()
|
||||||
|
repo := t.TempDir()
|
||||||
|
files := map[string]string{
|
||||||
|
"go.mod": "module example.com/cleanup\n\ngo 1.25.0\n",
|
||||||
|
"main.go": fmt.Sprintf("package main\nimport \"fmt\"\nfunc main() { fmt.Println(%q) }\n", output),
|
||||||
|
}
|
||||||
|
for name, contents := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, name), []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"init", "-b", "main"}, {"config", "user.name", "Fixture"}, {"config", "user.email", "fixture@example.invalid"}, {"add", "."}, {"commit", "-m", "fixture"}} {
|
||||||
|
command := exec.Command("git", args...)
|
||||||
|
command.Dir = repo
|
||||||
|
if data, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v: %s", args, err, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return releaseidentity.Identity{Selection: releaseidentity.Selection{RepositoryDir: repo, ModuleDir: repo}, ModulePath: "example.com/cleanup", Version: "v1.2.3", Tag: "v1.2.3"}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cancelWriter struct{ cancel context.CancelFunc }
|
||||||
|
|
||||||
|
func (writer cancelWriter) Write(p []byte) (int, error) {
|
||||||
|
if strings.Contains(string(p), "checking ") {
|
||||||
|
writer.cancel()
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeSmokeCleanupPreservesBuildOutcome(t *testing.T) {
|
||||||
|
for _, mode := range []string{"success", "smoke error", "cancellation"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
output := "fixture v1.2.3"
|
||||||
|
if mode == "smoke error" {
|
||||||
|
output = "wrong"
|
||||||
|
}
|
||||||
|
identity := cleanupFixture(t, output)
|
||||||
|
otherArch := "arm64"
|
||||||
|
if runtime.GOARCH == otherArch {
|
||||||
|
otherArch = "amd64"
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
request := Request{Identity: identity, OutputDir: t.TempDir(), Spec: Spec{MainPackage: ".", BinaryName: "fixture", Targets: []Target{{OS: runtime.GOOS, Arch: otherArch}}}}
|
||||||
|
if mode == "cancellation" {
|
||||||
|
request.Output = cancelWriter{cancel}
|
||||||
|
}
|
||||||
|
cleanupErr := errors.New("cleanup failed")
|
||||||
|
var removed string
|
||||||
|
result, err := buildResolved(ctx, request, func(path string) error {
|
||||||
|
removed = path
|
||||||
|
_ = os.RemoveAll(path)
|
||||||
|
return cleanupErr
|
||||||
|
})
|
||||||
|
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) || len(result.Artifacts) != 1 || result.Artifacts[0].SHA256 == "" || result.CandidateCommit == "" {
|
||||||
|
t.Fatalf("build = %#v, cleanup = %q, error = %v", result, removed, err)
|
||||||
|
}
|
||||||
|
if mode == "success" && !result.SourceVerified {
|
||||||
|
t.Fatal("completed build lost verified source")
|
||||||
|
}
|
||||||
|
if mode == "smoke error" && !strings.Contains(err.Error(), "native version output") {
|
||||||
|
t.Fatalf("smoke failure missing: %v", err)
|
||||||
|
}
|
||||||
|
if mode == "cancellation" && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation missing: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateBuildCleanupPreservesOutcome(t *testing.T) {
|
||||||
|
for _, mode := range []string{"success", "build failure", "cancellation"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
identity := cleanupFixture(t, "fixture v1.2.3")
|
||||||
|
cleanupErr := errors.New("cleanup failed")
|
||||||
|
var removed string
|
||||||
|
spec := &Spec{MainPackage: ".", BinaryName: "fixture", Targets: []Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}
|
||||||
|
if mode == "build failure" {
|
||||||
|
spec.MainPackage = "./missing"
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
var output io.Writer
|
||||||
|
if mode == "cancellation" {
|
||||||
|
output = cancelWriter{cancel}
|
||||||
|
}
|
||||||
|
callback := candidateCallback(spec, output, func(path string) error {
|
||||||
|
removed = path
|
||||||
|
_ = os.RemoveAll(path)
|
||||||
|
return cleanupErr
|
||||||
|
})
|
||||||
|
err := callback(ctx, candidate.Candidate{RepositoryDir: identity.RepositoryDir, ModuleDir: identity.ModuleDir, ModulePath: identity.ModulePath, Version: identity.Version, Tag: identity.Tag}, nil)
|
||||||
|
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) {
|
||||||
|
t.Fatalf("candidate cleanup = %q, error = %v", removed, err)
|
||||||
|
}
|
||||||
|
if mode == "build failure" && !strings.Contains(err.Error(), "build candidate") {
|
||||||
|
t.Fatalf("build failure missing: %v", err)
|
||||||
|
}
|
||||||
|
if mode == "cancellation" && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation missing: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,18 +194,18 @@ func inspectLocalState(ctx context.Context, repositoryDir string) (localState, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return localState{}, fmt.Errorf("inspect current branch: %w", err)
|
return localState{}, fmt.Errorf("inspect current branch: %w", err)
|
||||||
}
|
}
|
||||||
commitOutput, err := gitOutput(ctx, repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
|
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return localState{}, fmt.Errorf("capture HEAD commit: %w", err)
|
return localState{}, fmt.Errorf("capture HEAD commit: %w", err)
|
||||||
}
|
}
|
||||||
statusOutput, err := gitOutput(ctx, repositoryDir, "status", "--porcelain=v1", "--untracked-files=all")
|
status, err := git.WorktreeStatus(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return localState{}, fmt.Errorf("inspect worktree and index: %w", err)
|
return localState{}, fmt.Errorf("inspect worktree and index: %w", err)
|
||||||
}
|
}
|
||||||
return localState{
|
return localState{
|
||||||
branch: strings.TrimSpace(string(branchOutput)),
|
branch: strings.TrimSpace(string(branchOutput)),
|
||||||
commit: strings.TrimSpace(string(commitOutput)),
|
commit: commit,
|
||||||
status: strings.TrimSpace(string(statusOutput)),
|
status: status,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +215,7 @@ func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byt
|
|||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: commandArgs,
|
Args: commandArgs,
|
||||||
Dir: repositoryDir,
|
Dir: repositoryDir,
|
||||||
|
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
@@ -233,6 +234,7 @@ func hasLocalTag(ctx context.Context, repositoryDir, tag string) (bool, error) {
|
|||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
|
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
|
||||||
Dir: repositoryDir,
|
Dir: repositoryDir,
|
||||||
|
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -17,29 +17,15 @@ func declaredBuildAssets(result internalbuild.Result) ([]artifact.ExpectedAsset,
|
|||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if len(result.Artifacts) != len(settings.Targets) {
|
declarations := make([]artifact.Declaration, 0, len(result.Artifacts))
|
||||||
return nil, fmt.Errorf("build result must contain one artifact for each selected target")
|
for _, built := range result.Artifacts {
|
||||||
|
declarations = append(declarations, artifact.Declaration{Target: built.Target, Filename: built.Filename})
|
||||||
}
|
}
|
||||||
declarations := artifact.ExpectedBuild(settings.BinaryName, result.Version, settings.Targets)
|
if err := artifact.ValidateDeclarations(settings.BinaryName, result.Version, settings.Targets, declarations); err != nil {
|
||||||
wantByTarget := make(map[artifact.Target]string, len(declarations))
|
return nil, fmt.Errorf("build result: %w", err)
|
||||||
for _, declaration := range declarations {
|
|
||||||
target := declaration.Target
|
|
||||||
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
|
||||||
return nil, fmt.Errorf("build result contains an invalid target")
|
|
||||||
}
|
|
||||||
if _, exists := wantByTarget[target]; exists {
|
|
||||||
return nil, fmt.Errorf("build result repeats target %q", target.OS+"/"+target.Arch)
|
|
||||||
}
|
|
||||||
wantByTarget[target] = declaration.Filename
|
|
||||||
}
|
}
|
||||||
assets := make([]artifact.ExpectedAsset, 0, len(result.Artifacts)+1)
|
assets := make([]artifact.ExpectedAsset, 0, len(result.Artifacts)+1)
|
||||||
seenTargets := make(map[artifact.Target]bool, len(result.Artifacts))
|
|
||||||
for _, builtArtifact := range result.Artifacts {
|
for _, builtArtifact := range result.Artifacts {
|
||||||
wantName, ok := wantByTarget[builtArtifact.Target]
|
|
||||||
if !ok || seenTargets[builtArtifact.Target] || builtArtifact.Filename != wantName {
|
|
||||||
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", builtArtifact.Filename)
|
|
||||||
}
|
|
||||||
seenTargets[builtArtifact.Target] = true
|
|
||||||
if filepath.Base(builtArtifact.Path) != builtArtifact.Filename {
|
if filepath.Base(builtArtifact.Path) != builtArtifact.Filename {
|
||||||
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", builtArtifact.Path, builtArtifact.Filename)
|
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", builtArtifact.Path, builtArtifact.Filename)
|
||||||
}
|
}
|
||||||
|
|||||||
102
internal/delivery/cleanup_test.go
Normal file
102
internal/delivery/cleanup_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
|
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
func deliveryCleanupFixture(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
repo := t.TempDir()
|
||||||
|
files := map[string]string{
|
||||||
|
"go.mod": "module example.com/delivery\n\ngo 1.25.0\n",
|
||||||
|
"main.go": "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"fixture v1.2.3\") }\n",
|
||||||
|
"docs/releases/v1.2.3.md": "# Fixture v1.2.3\n\nNote.\n",
|
||||||
|
}
|
||||||
|
for name, contents := range files {
|
||||||
|
path := filepath.Join(repo, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"init", "-b", "main"}, {"config", "user.name", "Fixture"}, {"config", "user.email", "fixture@example.invalid"}, {"add", "."}, {"commit", "-m", "fixture"}, {"tag", "v1.2.3"}} {
|
||||||
|
command := exec.Command("git", args...)
|
||||||
|
command.Dir = repo
|
||||||
|
if data, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v: %s", args, err, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
command := exec.Command("git", "rev-parse", "HEAD")
|
||||||
|
command.Dir = repo
|
||||||
|
commit, err := command.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return repo, strings.TrimSpace(string(commit))
|
||||||
|
}
|
||||||
|
|
||||||
|
type cancelBuildOutput struct{ cancel context.CancelFunc }
|
||||||
|
|
||||||
|
func (writer cancelBuildOutput) Write(p []byte) (int, error) {
|
||||||
|
if strings.Contains(string(p), "building ") {
|
||||||
|
writer.cancel()
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedDeliveryCleanupPreservesOutcome(t *testing.T) {
|
||||||
|
for _, mode := range []string{"complete", "build failure", "cancellation"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
repo, commit := deliveryCleanupFixture(t)
|
||||||
|
note := "# Fixture v1.2.3\n\nNote.\n"
|
||||||
|
backend := &assetBackend{pageBackend: pageBackend{commit: commit, findResults: []pageFindResult{{release: forge.Release{Tag: "v1.2.3", Body: note, Handle: "release", URL: "https://example.invalid/release"}}}}, lists: []assetListResult{{}}}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
spec := internalbuild.Spec{MainPackage: ".", BinaryName: "fixture", Targets: []artifact.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}
|
||||||
|
if mode == "build failure" {
|
||||||
|
spec.MainPackage = "./missing"
|
||||||
|
}
|
||||||
|
request := Request{RepositoryDir: repo, Product: "Fixture", Version: "v1.2.3", Backend: backend, Build: &spec}
|
||||||
|
if mode == "cancellation" {
|
||||||
|
request.Output = cancelBuildOutput{cancel}
|
||||||
|
}
|
||||||
|
cleanupErr := errors.New("cleanup failed")
|
||||||
|
var removed string
|
||||||
|
result, err := deliver(ctx, request, func(path string) error {
|
||||||
|
removed = path
|
||||||
|
_ = os.RemoveAll(path)
|
||||||
|
return cleanupErr
|
||||||
|
})
|
||||||
|
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) || result.CandidateCommit != commit {
|
||||||
|
t.Fatalf("delivery = %#v, cleanup = %q, error = %v", result, removed, err)
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case "complete":
|
||||||
|
if result.State != Complete || len(result.Uploaded) != 2 || result.ReleaseURL == "" {
|
||||||
|
t.Fatalf("completed delivery lost observations: %#v", result)
|
||||||
|
}
|
||||||
|
case "build failure":
|
||||||
|
if result.State != NotAttempted || !strings.Contains(err.Error(), "build delivery assets") {
|
||||||
|
t.Fatalf("build failure lost: %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
case "cancellation":
|
||||||
|
if result.State != NotAttempted || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation lost: %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,11 @@ type Result struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Deliver(ctx context.Context, request Request) (Result, error) {
|
func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||||
result := Result{State: NotAttempted}
|
return deliver(ctx, request, os.RemoveAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliver(ctx context.Context, request Request, remove func(string) error) (result Result, resultErr error) {
|
||||||
|
result = Result{State: NotAttempted}
|
||||||
if !versioncontract.ValidStableVersion(request.Version) {
|
if !versioncontract.ValidStableVersion(request.Version) {
|
||||||
return result, candidate.ErrInvalidVersion
|
return result, candidate.ErrInvalidVersion
|
||||||
}
|
}
|
||||||
@@ -130,7 +134,11 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
|
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporaryDir)
|
defer func() {
|
||||||
|
if err := remove(temporaryDir); err != nil {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("remove delivery build directory %q: %w", temporaryDir, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
|
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
Spec: *request.Build,
|
Spec: *request.Build,
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ func inspectRemoteAssets(ctx context.Context, backend forge.Backend, releaseHand
|
|||||||
case 0:
|
case 0:
|
||||||
observation.State = AssetMissing
|
observation.State = AssetMissing
|
||||||
case 1:
|
case 1:
|
||||||
digest, err := remoteAssetDigest(ctx, backend, matches[0].Handle)
|
digest, sizeMatches, err := remoteAssetDigest(ctx, backend, matches[0].Handle, item.Size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observation.State = AssetUnverifiable
|
observation.State = AssetUnverifiable
|
||||||
observation.Err = err
|
observation.Err = err
|
||||||
} else if digest == item.SHA256 {
|
} else if sizeMatches && digest == item.SHA256 {
|
||||||
observation.State = AssetMatching
|
observation.State = AssetMatching
|
||||||
} else {
|
} else {
|
||||||
observation.State = AssetConflicting
|
observation.State = AssetConflicting
|
||||||
@@ -99,18 +99,40 @@ func inspectRemoteAssets(ctx context.Context, backend forge.Backend, releaseHand
|
|||||||
return inspection, nil
|
return inspection, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func remoteAssetDigest(ctx context.Context, backend forge.Backend, handle any) (string, error) {
|
func remoteAssetDigest(ctx context.Context, backend forge.Backend, handle any, expectedSize int64) (string, bool, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
stream, err := backend.OpenAsset(ctx, handle)
|
stream, err := backend.OpenAsset(ctx, handle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", false, err
|
||||||
}
|
}
|
||||||
hasher := sha256.New()
|
hasher := sha256.New()
|
||||||
_, readErr := io.Copy(hasher, stream)
|
read, readErr := io.Copy(hasher, io.LimitReader(stream, expectedSize))
|
||||||
closeErr := stream.Close()
|
excess := false
|
||||||
if err := errors.Join(readErr, closeErr); err != nil {
|
if readErr == nil && read == expectedSize {
|
||||||
return "", &forge.Failure{Kind: forge.FailureUnverifiable, Err: err}
|
var probe [1]byte
|
||||||
|
for attempts := 0; ; attempts++ {
|
||||||
|
var n int
|
||||||
|
n, readErr = stream.Read(probe[:])
|
||||||
|
excess = n != 0
|
||||||
|
if readErr != nil || excess {
|
||||||
|
if readErr == io.EOF {
|
||||||
|
readErr = nil
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -53,6 +53,88 @@ func TestInspectRemoteAssetsComparesContentAndPreservesUnrelatedAssets(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type controlledAssetStream struct {
|
||||||
|
*bytes.Reader
|
||||||
|
readErr error
|
||||||
|
errorAfter int
|
||||||
|
closeErr error
|
||||||
|
cancel context.CancelFunc
|
||||||
|
read int
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stream *controlledAssetStream) Read(p []byte) (int, error) {
|
||||||
|
n, err := stream.Reader.Read(p)
|
||||||
|
stream.read += n
|
||||||
|
if stream.cancel != nil {
|
||||||
|
stream.cancel()
|
||||||
|
stream.cancel = nil
|
||||||
|
}
|
||||||
|
if stream.readErr != nil && n > 0 && stream.read > stream.errorAfter {
|
||||||
|
err, stream.readErr = stream.readErr, nil
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stream *controlledAssetStream) Close() error {
|
||||||
|
stream.closed = true
|
||||||
|
return stream.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlledAssetBackend struct {
|
||||||
|
pageBackend
|
||||||
|
stream *controlledAssetStream
|
||||||
|
}
|
||||||
|
|
||||||
|
func (backend *controlledAssetBackend) ListAssets(context.Context, any) ([]forge.Asset, error) {
|
||||||
|
return []forge.Asset{{Filename: "asset.bin", Size: 999, Handle: "asset"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (backend *controlledAssetBackend) OpenAsset(context.Context, any) (io.ReadCloser, error) {
|
||||||
|
return backend.stream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectRemoteAssetBoundsContentAndPreservesErrors(t *testing.T) {
|
||||||
|
readErr := errors.New("read failed")
|
||||||
|
closeErr := errors.New("close failed")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, expected, actual string
|
||||||
|
readErr, closeErr error
|
||||||
|
cancel bool
|
||||||
|
errorAfter int
|
||||||
|
state AssetState
|
||||||
|
}{
|
||||||
|
{"empty", "", "", nil, nil, false, 0, AssetMatching},
|
||||||
|
{"shorter", "abcd", "abc", nil, nil, false, 0, AssetConflicting},
|
||||||
|
{"matching", "abcd", "abcd", nil, nil, false, 0, AssetMatching},
|
||||||
|
{"different", "abcd", "wxyz", nil, nil, false, 0, AssetConflicting},
|
||||||
|
{"oversized", "abcd", "abcdefghijk", nil, nil, false, 0, AssetConflicting},
|
||||||
|
{"empty oversized", "", "abc", nil, nil, false, 0, AssetConflicting},
|
||||||
|
{"data and error", "abcd", "abcd", readErr, nil, false, 0, AssetUnverifiable},
|
||||||
|
{"probe data and error", "abcd", "abcde", readErr, nil, false, 4, AssetUnverifiable},
|
||||||
|
{"close failure", "abcd", "abcdefgh", nil, closeErr, false, 0, AssetUnverifiable},
|
||||||
|
{"cancellation", "abcd", "abcd", nil, nil, true, 0, AssetUnverifiable},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
stream := &controlledAssetStream{Reader: bytes.NewReader([]byte(test.actual)), readErr: test.readErr, errorAfter: test.errorAfter, closeErr: test.closeErr}
|
||||||
|
if test.cancel {
|
||||||
|
stream.cancel = cancel
|
||||||
|
}
|
||||||
|
backend := &controlledAssetBackend{stream: stream}
|
||||||
|
inspection, err := inspectRemoteAssets(ctx, backend, "release", []artifact.ExpectedAsset{expectedAsset("asset.bin", []byte(test.expected))})
|
||||||
|
if err != nil || len(inspection.Assets) != 1 || inspection.Assets[0].State != test.state || !stream.closed || stream.read > len(test.expected)+1 {
|
||||||
|
t.Fatalf("inspection = %#v, error = %v, bytes = %d, closed = %t", inspection, err, stream.read, stream.closed)
|
||||||
|
}
|
||||||
|
cause := inspection.Assets[0].Err
|
||||||
|
if test.readErr != nil && !errors.Is(cause, test.readErr) || test.closeErr != nil && !errors.Is(cause, test.closeErr) || test.cancel && !errors.Is(cause, context.Canceled) {
|
||||||
|
t.Fatalf("inspection lost cause: %v", cause)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInspectRemoteAssetsListingFailureDoesNotBecomeUnknownWrite(t *testing.T) {
|
func TestInspectRemoteAssetsListingFailureDoesNotBecomeUnknownWrite(t *testing.T) {
|
||||||
assets := []artifact.ExpectedAsset{expectedAsset("a.bin", []byte("a")), expectedAsset("b.bin", []byte("b"))}
|
assets := []artifact.ExpectedAsset{expectedAsset("a.bin", []byte("a")), expectedAsset("b.bin", []byte("b"))}
|
||||||
listErr := &forge.Failure{Kind: forge.FailureTransport, Err: errors.New("service unavailable")}
|
listErr := &forge.Failure{Kind: forge.FailureTransport, Err: errors.New("service unavailable")}
|
||||||
@@ -230,6 +312,11 @@ func TestUploadMissingAssetsReconcilesOnceAndKeepsLaterAssetsPending(t *testing.
|
|||||||
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("x")},
|
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("x")},
|
||||||
wantState: Conflict, wantCategory: "conflicting",
|
wantState: Conflict, wantCategory: "conflicting",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "oversized bytes conflict", disposition: forge.WriteUncertain,
|
||||||
|
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("oversized")},
|
||||||
|
wantState: Conflict, wantCategory: "conflicting",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "missing after conflict remains conflict", disposition: forge.WriteConflict,
|
name: "missing after conflict remains conflict", disposition: forge.WriteConflict,
|
||||||
wantState: Conflict, wantCategory: "conflicting",
|
wantState: Conflict, wantCategory: "conflicting",
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ func (reader *BlobReader) Read(ctx context.Context, objectID string) ([]byte, er
|
|||||||
|
|
||||||
reader.mu.Lock()
|
reader.mu.Lock()
|
||||||
defer reader.mu.Unlock()
|
defer reader.mu.Unlock()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
|
||||||
|
}
|
||||||
if reader.terminal != nil {
|
if reader.terminal != nil {
|
||||||
return nil, reader.terminal
|
return nil, reader.terminal
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
43
internal/git/display.go
Normal 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 }
|
||||||
103
internal/git/display_test.go
Normal file
103
internal/git/display_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisplayURL(t *testing.T) {
|
||||||
|
for _, test := range []struct{ input, want string }{
|
||||||
|
{"https://user:password@example.test/repo", "https://redacted@example.test/repo"},
|
||||||
|
{"https://token@example.test/repo", "https://redacted@example.test/repo"},
|
||||||
|
{"https://user:secret'part@example.test/repo", "https://redacted@example.test/repo"},
|
||||||
|
{"https://user%40example:secret%2Fvalue@example.test/repo", "https://redacted@example.test/repo"},
|
||||||
|
{"http://broken%zz:secret@example.test/repo", "[redacted URL]"},
|
||||||
|
{"https://example.test/repo", "https://example.test/repo"},
|
||||||
|
{"git@example.test:owner/repo", "git@example.test:owner/repo"},
|
||||||
|
} {
|
||||||
|
if got := DisplayURL(test.input); got != test.want {
|
||||||
|
t.Errorf("DisplayURL(%q) = %q, want %q", test.input, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayDiagnosticRedactsUserinfoBeforeQuotedContext(t *testing.T) {
|
||||||
|
for _, test := range []struct{ input, want string }{
|
||||||
|
{"fatal: 'https://user:secret'part@example.test/repo': denied", "fatal: 'https://redacted@example.test/repo': denied"},
|
||||||
|
{"failed at https://token'part@example.test", "failed at https://redacted@example.test"},
|
||||||
|
{"fatal: 'https://user:secret%27part@example.test/repo': denied", "fatal: 'https://redacted@example.test/repo': denied"},
|
||||||
|
{"fatal: 'https://broken%zz:secret'part@example.test/repo': denied", "fatal: '[redacted URL]': denied"},
|
||||||
|
{"fatal: 'https://token'part@': denied", "fatal: '[redacted URL]': denied"},
|
||||||
|
{"see 'https://example.test/path' and git@example.test:repo", "see 'https://example.test/path' and git@example.test:repo"},
|
||||||
|
} {
|
||||||
|
if got := displayDiagnostic(test.input); got != test.want {
|
||||||
|
t.Errorf("displayDiagnostic(%q) = %q, want %q", test.input, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRedactsCredentialsInNativeGitErrors(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
for _, remote := range []string{"https://user:secret'part@example.test/repo", "https://user:secret%27part@example.test/repo"} {
|
||||||
|
_, err := Resolve(t.Context(), fixture.work, remote)
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "user:") || !strings.Contains(err.Error(), "example.test/repo") {
|
||||||
|
t.Fatalf("unsafe or incomplete native Git diagnostic: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitFailuresRedactCredentialsAndPreserveCause(t *testing.T) {
|
||||||
|
installFakeGit(t, `case "$*" in
|
||||||
|
*check-ref-format*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' "fatal: unable to access 'https://user:secret@example.test/repo': https://token@example.test/path" >&2
|
||||||
|
exit 23`)
|
||||||
|
endpoint := Endpoint{PushURL: "https://user:secret@example.test/repo", ScopedName: "fixture"}
|
||||||
|
for _, operation := range []struct {
|
||||||
|
name string
|
||||||
|
run func() error
|
||||||
|
}{
|
||||||
|
{"query", func() error { _, err := endpoint.Query(context.Background(), t.TempDir(), "v1.2.3"); return err }},
|
||||||
|
{"push", func() error {
|
||||||
|
return endpoint.PushTag(context.Background(), t.TempDir(), "v1.2.3", strings.Repeat("a", 40))
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(operation.name, func(t *testing.T) {
|
||||||
|
err := operation.run()
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || exit.ExitCode() != 23 {
|
||||||
|
t.Fatalf("error = %v, want Git exit 23", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "token") || strings.Contains(err.Error(), "user") || !strings.Contains(err.Error(), "example.test/repo") {
|
||||||
|
t.Fatalf("unsafe or unhelpful diagnostic: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayErrorPreservesWrappedCause(t *testing.T) {
|
||||||
|
for _, userinfo := range []string{"token", "token'part", "token%27part"} {
|
||||||
|
cause := errors.New("failed at https://" + userinfo + "@example.test/path")
|
||||||
|
err := displayError{cause: cause}
|
||||||
|
if !errors.Is(err, cause) || strings.Contains(err.Error(), "token") {
|
||||||
|
t.Fatalf("sanitized error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndpointChangeRedactsNewDestination(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
endpoint, err := Resolve(t.Context(), fixture.work, "origin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gitRun(t, fixture.work, "remote", "set-url", "--push", "origin", "https://new-token@example.test/repo")
|
||||||
|
err = endpoint.Recheck(t.Context(), fixture.work)
|
||||||
|
if !errors.Is(err, ErrEndpointChanged) || strings.Contains(err.Error(), "new-token") || !strings.Contains(err.Error(), "example.test/repo") {
|
||||||
|
t.Fatalf("endpoint change error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expe
|
|||||||
}
|
}
|
||||||
ref := "refs/tags/" + tag
|
ref := "refs/tags/" + tag
|
||||||
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
|
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
|
||||||
return fmt.Errorf("fetch tag %s from remote %s: %w", tag, remote, err)
|
return fmt.Errorf("fetch tag %s from remote %s: %w", tag, DisplayURL(remote), err)
|
||||||
}
|
}
|
||||||
source, err := InspectTaggedSource(ctx, repositoryDir, tag)
|
source, err := InspectTaggedSource(ctx, repositoryDir, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := InspectLocalTag(context.Background(), fixture.work, tag, object); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
|
source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -54,6 +51,31 @@ func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateAnnotatedTagReportsTemporaryCleanupFailure(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
cleanupErr := errors.New("cleanup failed")
|
||||||
|
var removed string
|
||||||
|
remove := func(path string) error {
|
||||||
|
removed = path
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cleanupErr
|
||||||
|
}
|
||||||
|
object, err := createAnnotatedTag(t.Context(), fixture.work, "v1.2.3", fixture.commit, []byte("note\n"), remove)
|
||||||
|
if !errors.Is(err, cleanupErr) || object == "" || removed == "" || !strings.Contains(err.Error(), removed) {
|
||||||
|
t.Fatalf("tag object = %q, cleanup path = %q, error = %v", object, removed, err)
|
||||||
|
}
|
||||||
|
if got := gitText(t, fixture.work, "rev-parse", "refs/tags/v1.2.3"); got != object {
|
||||||
|
t.Fatalf("local tag = %q, want %q", got, object)
|
||||||
|
}
|
||||||
|
_, err = createAnnotatedTag(t.Context(), fixture.work, "v1.2.3", fixture.commit, []byte("note\n"), remove)
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || !errors.Is(err, cleanupErr) {
|
||||||
|
t.Fatalf("tag creation and cleanup error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTagReferenceValidationUsesGitRules(t *testing.T) {
|
func TestTagReferenceValidationUsesGitRules(t *testing.T) {
|
||||||
fixture := newRemoteFixture(t)
|
fixture := newRemoteFixture(t)
|
||||||
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} {
|
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} {
|
||||||
@@ -142,3 +164,52 @@ func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T)
|
|||||||
t.Fatalf("dirty status = %q", source.Status)
|
t.Fatalf("dirty status = %q", source.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCommittedReadsIgnoreReplacementObjects(t *testing.T) {
|
||||||
|
for _, kind := range []string{"commit", "tree", "blob"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
original := map[string]string{
|
||||||
|
"commit": fixture.commit,
|
||||||
|
"tree": gitText(t, fixture.work, "rev-parse", "HEAD^{tree}"),
|
||||||
|
"blob": gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"),
|
||||||
|
}
|
||||||
|
writeRepoFile(t, fixture.work, "source.txt", "replacement")
|
||||||
|
gitRun(t, fixture.work, "add", "source.txt")
|
||||||
|
gitRun(t, fixture.work, "commit", "-m", "Replacement")
|
||||||
|
replacement := map[string]string{
|
||||||
|
"commit": gitText(t, fixture.work, "rev-parse", "HEAD"),
|
||||||
|
"tree": gitText(t, fixture.work, "rev-parse", "HEAD^{tree}"),
|
||||||
|
"blob": gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"),
|
||||||
|
}
|
||||||
|
gitRun(t, fixture.work, "reset", "--hard", fixture.commit)
|
||||||
|
gitRun(t, fixture.work, "replace", original[kind], replacement[kind])
|
||||||
|
|
||||||
|
content, err := ReadCommitFile(t.Context(), fixture.work, fixture.commit, "source.txt")
|
||||||
|
if err != nil || string(content) != "candidate" {
|
||||||
|
t.Fatalf("committed content = %q, error = %v", content, err)
|
||||||
|
}
|
||||||
|
entries, err := ReadTree(t.Context(), fixture.work, fixture.commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var object string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Path == "source.txt" {
|
||||||
|
object = entry.ObjectID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if object != original["blob"] {
|
||||||
|
t.Fatalf("tree blob = %q, want %q", object, original["blob"])
|
||||||
|
}
|
||||||
|
reader := NewBlobReader(t.Context(), fixture.work)
|
||||||
|
content, err = reader.Read(t.Context(), object)
|
||||||
|
if err != nil || string(content) != "candidate" {
|
||||||
|
t.Fatalf("batch content = %q, error = %v", content, err)
|
||||||
|
}
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ package git
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
|
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
|
||||||
// on the captured commit and returns the tag object's ID.
|
// on the captured commit. Once its type and ID are established, the ID is
|
||||||
|
// returned even if a later verification fails.
|
||||||
func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
|
func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
|
||||||
|
return createAnnotatedTag(ctx, repositoryDir, tag, commit, note, os.Remove)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte, remove func(string) error) (objectID string, resultErr error) {
|
||||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -22,7 +28,11 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string,
|
|||||||
return "", fmt.Errorf("create temporary release note: %w", err)
|
return "", fmt.Errorf("create temporary release note: %w", err)
|
||||||
}
|
}
|
||||||
messagePath := message.Name()
|
messagePath := message.Name()
|
||||||
defer os.Remove(messagePath)
|
defer func() {
|
||||||
|
if err := remove(messagePath); err != nil {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary release note %q: %w", messagePath, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
if _, err := message.Write(note); err != nil {
|
if _, err := message.Write(note); err != nil {
|
||||||
_ = message.Close()
|
_ = message.Close()
|
||||||
return "", fmt.Errorf("write temporary release note: %w", err)
|
return "", fmt.Errorf("write temporary release note: %w", err)
|
||||||
@@ -35,66 +45,47 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
ref := "refs/tags/" + tag
|
ref := "refs/tags/" + tag
|
||||||
|
object, err := inspectCreatedTag(ctx, repositoryDir, ref, commit, note)
|
||||||
|
if err != nil {
|
||||||
|
return object, fmt.Errorf("verify created tag %s; local ref may remain: %w", ref, err)
|
||||||
|
}
|
||||||
|
return object, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectCreatedTag(ctx context.Context, repositoryDir, ref, commit string, note []byte) (string, error) {
|
||||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("inspect created tag %s: %w", tag, err)
|
return "", fmt.Errorf("inspect created tag %s: %w", ref, err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(tagType) != "tag" {
|
if strings.TrimSpace(tagType) != "tag" {
|
||||||
return "", fmt.Errorf("created tag %s has object type %q, want tag", tag, strings.TrimSpace(tagType))
|
return "", fmt.Errorf("created tag %s has object type %q, want tag", ref, strings.TrimSpace(tagType))
|
||||||
}
|
}
|
||||||
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read created tag object %s: %w", tag, err)
|
return "", fmt.Errorf("read created tag object %s: %w", ref, err)
|
||||||
}
|
}
|
||||||
tagObject = strings.TrimSpace(tagObject)
|
tagObject = strings.TrimSpace(tagObject)
|
||||||
if !validObjectID(tagObject) {
|
if !validObjectID(tagObject) {
|
||||||
return "", fmt.Errorf("created tag %s has invalid object ID %q", tag, tagObject)
|
return "", fmt.Errorf("created tag %s has invalid object ID %q", ref, tagObject)
|
||||||
}
|
}
|
||||||
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
|
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read peeled commit for tag %s: %w", tag, err)
|
return tagObject, fmt.Errorf("read peeled commit for tag %s: %w", ref, err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(peeled) != commit {
|
if strings.TrimSpace(peeled) != commit {
|
||||||
return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", tag, strings.TrimSpace(peeled), commit)
|
return tagObject, fmt.Errorf("created tag %s peels to %s, want candidate %s", ref, strings.TrimSpace(peeled), commit)
|
||||||
}
|
}
|
||||||
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
|
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read annotated tag %s: %w", tag, err)
|
return tagObject, fmt.Errorf("read annotated tag %s: %w", ref, err)
|
||||||
}
|
}
|
||||||
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
|
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
|
||||||
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
|
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
|
||||||
return "", fmt.Errorf("annotated tag %s message does not match the captured release note", tag)
|
return tagObject, fmt.Errorf("annotated tag %s message does not match the captured release note", ref)
|
||||||
}
|
}
|
||||||
return tagObject, nil
|
return tagObject, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InspectLocalTag confirms the selected tag still points to the exact
|
|
||||||
// annotated tag object captured before publication.
|
|
||||||
func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error {
|
|
||||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !validObjectID(expectedObject) {
|
|
||||||
return fmt.Errorf("invalid expected tag object identity %q at %q", expectedObject, tag)
|
|
||||||
}
|
|
||||||
ref := "refs/tags/" + tag
|
|
||||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("inspect local tag %s: %w", tag, err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(tagType) != "tag" {
|
|
||||||
return fmt.Errorf("local tag %s has type %q, want annotated tag", tag, strings.TrimSpace(tagType))
|
|
||||||
}
|
|
||||||
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read local tag %s: %w", tag, err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(object) != expectedObject {
|
|
||||||
return fmt.Errorf("local tag %s identifies %s, want %s", tag, strings.TrimSpace(object), expectedObject)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryTag verifies the captured endpoint still resolves to itself, then reads
|
// QueryTag verifies the captured endpoint still resolves to itself, then reads
|
||||||
// one exact authoritative version tag and its peeled commit. Failed
|
// one exact authoritative version tag and its peeled commit. Failed
|
||||||
// verification or reads are unreadable and never interpreted as absence.
|
// verification or reads are unreadable and never interpreted as absence.
|
||||||
@@ -109,13 +100,13 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string
|
|||||||
peeledRef := ref + "^{}"
|
peeledRef := ref + "^{}"
|
||||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, endpoint.PushURL, err)
|
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, DisplayURL(endpoint.PushURL), err)
|
||||||
}
|
}
|
||||||
remoteTag := RemoteRef{State: RefAbsent}
|
remoteTag := RemoteRef{State: RefAbsent}
|
||||||
for _, line := range outputLines(output) {
|
for _, line := range outputLines(output) {
|
||||||
object, name, ok := strings.Cut(line, "\t")
|
object, name, ok := strings.Cut(line, "\t")
|
||||||
if !ok || !validObjectID(object) {
|
if !ok || !validObjectID(object) {
|
||||||
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
|
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, displayDiagnostic(line))
|
||||||
}
|
}
|
||||||
switch name {
|
switch name {
|
||||||
case ref:
|
case ref:
|
||||||
@@ -136,8 +127,8 @@ func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObj
|
|||||||
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
|
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
|
||||||
}
|
}
|
||||||
refspec := tagObject + ":refs/tags/" + tag
|
refspec := tagObject + ":refs/tags/" + tag
|
||||||
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--porcelain", endpoint.ScopedName, refspec); err != nil {
|
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--recurse-submodules=no", "--porcelain", endpoint.ScopedName, refspec); err != nil {
|
||||||
return fmt.Errorf("push annotated tag %s to %s: %w", tag, endpoint.PushURL, err)
|
return fmt.Errorf("push annotated tag %s to %s: %w", tag, DisplayURL(endpoint.PushURL), err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.UR
|
|||||||
}
|
}
|
||||||
assetURL.Fragment = ""
|
assetURL.Fragment = ""
|
||||||
visited := make(map[string]struct{})
|
visited := make(map[string]struct{})
|
||||||
|
followed := 0
|
||||||
for {
|
for {
|
||||||
key := assetURL.String()
|
key := assetURL.String()
|
||||||
if _, exists := visited[key]; exists {
|
if _, exists := visited[key]; exists {
|
||||||
@@ -75,6 +76,10 @@ func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.UR
|
|||||||
return nil, failure(FailureUnverifiable, "download release asset", 0, err)
|
return nil, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||||
}
|
}
|
||||||
if isRedirect(response.StatusCode) {
|
if isRedirect(response.StatusCode) {
|
||||||
|
if followed == 10 {
|
||||||
|
_ = response.Body.Close()
|
||||||
|
return nil, failure(FailureUnverifiable, "redirect release asset: redirect limit exceeded", response.StatusCode, errors.New("at most 10 redirects are allowed"))
|
||||||
|
}
|
||||||
location, locationErr := response.Location()
|
location, locationErr := response.Location()
|
||||||
_ = response.Body.Close()
|
_ = response.Body.Close()
|
||||||
if locationErr != nil || location.User != nil {
|
if locationErr != nil || location.User != nil {
|
||||||
@@ -82,6 +87,7 @@ func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.UR
|
|||||||
}
|
}
|
||||||
location.Fragment = ""
|
location.Fragment = ""
|
||||||
assetURL = location
|
assetURL = location
|
||||||
|
followed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
|||||||
@@ -4,15 +4,104 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.maximumdirect.net/releasekit/internal/forge"
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestBackendOpenAssetBoundsRedirects(t *testing.T) {
|
||||||
|
for _, hops := range []int{0, 10, 11} {
|
||||||
|
t.Run(strconv.Itoa(hops), func(t *testing.T) {
|
||||||
|
var requests atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
|
||||||
|
t.Errorf("authorization = %q", got)
|
||||||
|
}
|
||||||
|
step, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/"))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("invalid path %q", r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if step < hops {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/%d", step+1), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "asset")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
stream, err := backend.OpenAsset(t.Context(), assetHandle{downloadURL: "/0"})
|
||||||
|
if hops <= 10 {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(stream)
|
||||||
|
closeErr := stream.Close()
|
||||||
|
if readErr != nil || closeErr != nil || string(body) != "asset" || requests.Load() != int32(hops+1) {
|
||||||
|
t.Fatalf("body = %q, read = %v, close = %v, requests = %d", body, readErr, closeErr, requests.Load())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "redirect limit") || requests.Load() != 11 || stream != nil {
|
||||||
|
t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendOpenAssetRedirectReturnsToOrigin(t *testing.T) {
|
||||||
|
var origin *httptest.Server
|
||||||
|
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Errorf("cross-origin authorization = %q", got)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, origin.URL+"/final", http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer external.Close()
|
||||||
|
origin = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
|
||||||
|
t.Errorf("origin authorization = %q", got)
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/start" {
|
||||||
|
http.Redirect(w, r, external.URL+"/away", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "asset")
|
||||||
|
}))
|
||||||
|
defer origin.Close()
|
||||||
|
stream, err := NewBackend(testDestination(origin.URL), origin.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/start"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
if body, err := io.ReadAll(stream); err != nil || string(body) != "asset" {
|
||||||
|
t.Fatalf("body = %q, error = %v", body, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendOpenAssetRejectsRedirectCycle(t *testing.T) {
|
||||||
|
var requests atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
http.Redirect(w, r, "/loop#different-fragment", http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
stream, err := NewBackend(testDestination(server.URL), server.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/loop"})
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "loop") || requests.Load() != 1 || stream != nil {
|
||||||
|
t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) {
|
func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/repos/owner/project/releases/10/assets" {
|
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/repos/owner/project/releases/10/assets" {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func New(ctx context.Context, directory string) (*Tool, error) {
|
|||||||
// local Go toolchain. It preserves caller module-network settings and caches,
|
// local Go toolchain. It preserves caller module-network settings and caches,
|
||||||
// while disabling workspace discovery and automatic toolchain downloads.
|
// while disabling workspace discovery and automatic toolchain downloads.
|
||||||
func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
||||||
captured := &combinedWriter{}
|
captured := &combinedWriter{mode: captureStatus}
|
||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: []string{"go", "mod", "download", "-modfile="},
|
Args: []string{"go", "mod", "download", "-modfile="},
|
||||||
Dir: directory,
|
Dir: directory,
|
||||||
@@ -107,7 +107,8 @@ func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
|||||||
Stderr: captured,
|
Stderr: captured,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if output := strings.TrimSpace(string(captured.take())); output != "" {
|
omitted := captured.omitted()
|
||||||
|
if output := diagnosticText(captured.take(), omitted); output != "" {
|
||||||
return fmt.Errorf("go mod download: %w: %s", err, output)
|
return fmt.Errorf("go mod download: %w: %s", err, output)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("go mod download: %w", err)
|
return fmt.Errorf("go mod download: %w", err)
|
||||||
@@ -140,7 +141,11 @@ func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, targ
|
|||||||
return nil, errors.New("Go command requires arguments")
|
return nil, errors.New("Go command requires arguments")
|
||||||
}
|
}
|
||||||
environment := tool.environment(mode, target)
|
environment := tool.environment(mode, target)
|
||||||
captured := &combinedWriter{output: output}
|
modeCapture := captureStatus
|
||||||
|
if keepOutput {
|
||||||
|
modeCapture = captureFull
|
||||||
|
}
|
||||||
|
captured := &combinedWriter{output: output, mode: modeCapture}
|
||||||
commandArgs := append([]string{"go"}, args...)
|
commandArgs := append([]string{"go"}, args...)
|
||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: commandArgs,
|
Args: commandArgs,
|
||||||
@@ -152,16 +157,28 @@ func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, targ
|
|||||||
if forwardErr := captured.forwardingError(); forwardErr != nil {
|
if forwardErr := captured.forwardingError(); forwardErr != nil {
|
||||||
err = errors.Join(err, fmt.Errorf("forward Go output: %w", forwardErr))
|
err = errors.Join(err, fmt.Errorf("forward Go output: %w", forwardErr))
|
||||||
}
|
}
|
||||||
|
if err == nil && !keepOutput {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
omitted := captured.omitted()
|
||||||
contents := captured.take()
|
contents := captured.take()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if len(contents) != 0 {
|
if diagnostic := diagnosticText(contents, omitted); diagnostic != "" {
|
||||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
|
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, diagnostic)
|
||||||
}
|
}
|
||||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
|
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
|
||||||
}
|
}
|
||||||
return outputBytes(contents, keepOutput), nil
|
return outputBytes(contents, keepOutput), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func diagnosticText(contents []byte, omitted bool) string {
|
||||||
|
text := strings.TrimSpace(string(contents))
|
||||||
|
if omitted {
|
||||||
|
return "[earlier Go output omitted]\n" + text
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
func outputBytes(contents []byte, keep bool) []byte {
|
func outputBytes(contents []byte, keep bool) []byte {
|
||||||
if keep {
|
if keep {
|
||||||
return contents
|
return contents
|
||||||
@@ -232,17 +249,63 @@ type combinedWriter struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
output io.Writer
|
output io.Writer
|
||||||
buffer bytes.Buffer
|
buffer bytes.Buffer
|
||||||
|
mode captureMode
|
||||||
|
tail []byte
|
||||||
|
head int
|
||||||
|
truncated bool
|
||||||
forwardErr error
|
forwardErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type captureMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
captureFull captureMode = iota
|
||||||
|
captureStatus
|
||||||
|
statusDiagnosticLimit = 1 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
func (writer *combinedWriter) capture(contents []byte) {
|
||||||
|
if writer.mode == captureFull {
|
||||||
|
_, _ = writer.buffer.Write(contents)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(contents) >= statusDiagnosticLimit {
|
||||||
|
writer.truncated = writer.truncated || len(writer.tail) != 0 || len(contents) > statusDiagnosticLimit
|
||||||
|
if cap(writer.tail) < statusDiagnosticLimit {
|
||||||
|
writer.tail = make([]byte, statusDiagnosticLimit)
|
||||||
|
} else {
|
||||||
|
writer.tail = writer.tail[:statusDiagnosticLimit]
|
||||||
|
}
|
||||||
|
copy(writer.tail, contents[len(contents)-statusDiagnosticLimit:])
|
||||||
|
writer.head = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if available := statusDiagnosticLimit - len(writer.tail); available > 0 {
|
||||||
|
n := min(available, len(contents))
|
||||||
|
if needed := len(writer.tail) + n; needed > cap(writer.tail) {
|
||||||
|
capacity := min(statusDiagnosticLimit, max(needed, 2*cap(writer.tail)))
|
||||||
|
grown := make([]byte, len(writer.tail), capacity)
|
||||||
|
copy(grown, writer.tail)
|
||||||
|
writer.tail = grown
|
||||||
|
}
|
||||||
|
writer.tail = append(writer.tail, contents[:n]...)
|
||||||
|
contents = contents[n:]
|
||||||
|
}
|
||||||
|
if len(contents) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.truncated = true
|
||||||
|
n := copy(writer.tail[writer.head:], contents)
|
||||||
|
copy(writer.tail, contents[n:])
|
||||||
|
writer.head = (writer.head + len(contents)) % statusDiagnosticLimit
|
||||||
|
}
|
||||||
|
|
||||||
func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
||||||
writer.mu.Lock()
|
writer.mu.Lock()
|
||||||
defer writer.mu.Unlock()
|
defer writer.mu.Unlock()
|
||||||
|
writer.capture(contents)
|
||||||
if writer.output == nil {
|
if writer.output == nil {
|
||||||
return writer.buffer.Write(contents)
|
return len(contents), nil
|
||||||
}
|
|
||||||
if _, err := writer.buffer.Write(contents); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
written, err := writer.output.Write(contents)
|
written, err := writer.output.Write(contents)
|
||||||
if err == nil && written != len(contents) {
|
if err == nil && written != len(contents) {
|
||||||
@@ -254,6 +317,12 @@ func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
|||||||
return written, err
|
return written, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (writer *combinedWriter) omitted() bool {
|
||||||
|
writer.mu.Lock()
|
||||||
|
defer writer.mu.Unlock()
|
||||||
|
return writer.truncated
|
||||||
|
}
|
||||||
|
|
||||||
func (writer *combinedWriter) forwardingError() error {
|
func (writer *combinedWriter) forwardingError() error {
|
||||||
writer.mu.Lock()
|
writer.mu.Lock()
|
||||||
defer writer.mu.Unlock()
|
defer writer.mu.Unlock()
|
||||||
@@ -265,5 +334,18 @@ func (writer *combinedWriter) forwardingError() error {
|
|||||||
func (writer *combinedWriter) take() []byte {
|
func (writer *combinedWriter) take() []byte {
|
||||||
writer.mu.Lock()
|
writer.mu.Lock()
|
||||||
defer writer.mu.Unlock()
|
defer writer.mu.Unlock()
|
||||||
return writer.buffer.Bytes()
|
if writer.mode == captureFull {
|
||||||
|
contents := writer.buffer.Bytes()
|
||||||
|
writer.buffer = bytes.Buffer{}
|
||||||
|
return contents
|
||||||
|
}
|
||||||
|
contents := writer.tail
|
||||||
|
if writer.head != 0 {
|
||||||
|
contents = make([]byte, 0, len(writer.tail))
|
||||||
|
contents = append(contents, writer.tail[writer.head:]...)
|
||||||
|
contents = append(contents, writer.tail[:writer.head]...)
|
||||||
|
}
|
||||||
|
writer.tail = nil
|
||||||
|
writer.head = 0
|
||||||
|
return contents
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -453,6 +455,96 @@ func TestOutputReturnsLargeFailureDiagnosticsAndCause(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatusCaptureRetainsTailAndForwardsAll(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
size int
|
||||||
|
chunkSize int
|
||||||
|
}{
|
||||||
|
{"below", statusDiagnosticLimit - 1, 8191},
|
||||||
|
{"exact", statusDiagnosticLimit, 8191},
|
||||||
|
{"above", statusDiagnosticLimit + 1, 8191},
|
||||||
|
{"single oversized", statusDiagnosticLimit*2 + 3, statusDiagnosticLimit*2 + 3},
|
||||||
|
{"wrapped small writes", statusDiagnosticLimit*3 + 17, 113},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
input := make([]byte, test.size)
|
||||||
|
for i := range input {
|
||||||
|
input[i] = byte(i % 251)
|
||||||
|
}
|
||||||
|
var forwarded bytes.Buffer
|
||||||
|
writer := &combinedWriter{mode: captureStatus, output: &forwarded}
|
||||||
|
for start := 0; start < len(input); start += test.chunkSize {
|
||||||
|
chunk := input[start:min(start+test.chunkSize, len(input))]
|
||||||
|
if n, err := writer.Write(chunk); n != len(chunk) || err != nil {
|
||||||
|
t.Fatalf("write = %d, %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if writer.omitted() != (len(input) > statusDiagnosticLimit) || cap(writer.tail) > statusDiagnosticLimit {
|
||||||
|
t.Fatalf("omitted = %t, capacity = %d", writer.omitted(), cap(writer.tail))
|
||||||
|
}
|
||||||
|
want := input[max(0, len(input)-statusDiagnosticLimit):]
|
||||||
|
if got := writer.take(); !bytes.Equal(got, want) || !bytes.Equal(forwarded.Bytes(), input) {
|
||||||
|
t.Fatalf("capture size = %d, forwarded size = %d", len(got), forwarded.Len())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusCaptureSerializesConcurrentWriters(t *testing.T) {
|
||||||
|
var forwarded bytes.Buffer
|
||||||
|
writer := &combinedWriter{mode: captureStatus, output: &forwarded}
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for _, value := range []byte{'a', 'b'} {
|
||||||
|
group.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
chunk := bytes.Repeat([]byte{value}, 4096)
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
_, _ = writer.Write(chunk)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
group.Wait()
|
||||||
|
contents := forwarded.Bytes()
|
||||||
|
if !writer.omitted() || !bytes.Equal(writer.take(), contents[len(contents)-statusDiagnosticLimit:]) {
|
||||||
|
t.Fatal("concurrent capture differs from forwarded tail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusCommandsRetainBoundedFailureDiagnostics(t *testing.T) {
|
||||||
|
repository, _ := installGoPreparationFixture(t)
|
||||||
|
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "large-fail")
|
||||||
|
var forwarded bytes.Buffer
|
||||||
|
err := fixtureTool().Run(context.Background(), repository, Native, Target{}, &forwarded, "version")
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "earlier Go output omitted") || !strings.Contains(err.Error(), "fixture large failure") || len(err.Error()) > statusDiagnosticLimit+200 || forwarded.Len() != (8<<20)+len("fixture large failure\n") {
|
||||||
|
t.Fatalf("Run() error = %v, forwarded = %d bytes", err, forwarded.Len())
|
||||||
|
}
|
||||||
|
err = PrepareBuildDependencies(context.Background(), repository)
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "earlier Go output omitted") || !strings.Contains(err.Error(), "fixture large failure") || len(err.Error()) > statusDiagnosticLimit+200 {
|
||||||
|
t.Fatalf("preparation error did not retain bounded diagnostics: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusCommandPreservesForwardingError(t *testing.T) {
|
||||||
|
repository, _ := installGoPreparationFixture(t)
|
||||||
|
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output")
|
||||||
|
writerErr := errors.New("forwarding failed")
|
||||||
|
err := fixtureTool().Run(context.Background(), repository, Native, Target{}, failingOutputWriter{err: writerErr}, "version")
|
||||||
|
if !errors.Is(err, writerErr) {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
err = fixtureTool().Run(context.Background(), repository, Native, Target{}, shortOutputWriter{}, "version")
|
||||||
|
if !errors.Is(err, io.ErrShortWrite) {
|
||||||
|
t.Fatalf("Run() short-write error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type shortOutputWriter struct{}
|
||||||
|
|
||||||
|
func (shortOutputWriter) Write([]byte) (int, error) { return 0, nil }
|
||||||
|
|
||||||
func TestOutputReturnsCancellation(t *testing.T) {
|
func TestOutputReturnsCancellation(t *testing.T) {
|
||||||
repository, recordPath := installGoPreparationFixture(t)
|
repository, recordPath := installGoPreparationFixture(t)
|
||||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait")
|
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait")
|
||||||
|
|||||||
@@ -20,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
154
internal/gotool/recovery.go
Normal 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
|
||||||
|
}
|
||||||
22
internal/gotool/recovery_test.go
Normal file
22
internal/gotool/recovery_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Invocation describes one child process. UnsetEnv removes inherited values
|
// Invocation describes one child process. UnsetEnv removes inherited values
|
||||||
@@ -54,6 +54,8 @@ type PipedProcess struct {
|
|||||||
stdout io.ReadCloser
|
stdout io.ReadCloser
|
||||||
parent context.Context
|
parent context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
stopIO func() bool
|
||||||
|
ioDone <-chan struct{}
|
||||||
waitOnce sync.Once
|
waitOnce sync.Once
|
||||||
waitErr error
|
waitErr error
|
||||||
}
|
}
|
||||||
@@ -79,6 +81,9 @@ func (child *PipedProcess) Cancel() {
|
|||||||
func (child *PipedProcess) Wait() error {
|
func (child *PipedProcess) Wait() error {
|
||||||
child.waitOnce.Do(func() {
|
child.waitOnce.Do(func() {
|
||||||
err := child.command.Wait()
|
err := child.command.Wait()
|
||||||
|
if !child.stopIO() {
|
||||||
|
<-child.ioDone
|
||||||
|
}
|
||||||
child.cancel()
|
child.cancel()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
@@ -120,12 +125,20 @@ func StartPiped(ctx context.Context, invocation Invocation) (*PipedProcess, erro
|
|||||||
cancel()
|
cancel()
|
||||||
return nil, fmt.Errorf("start %q: %w", invocation.Args[0], err)
|
return nil, fmt.Errorf("start %q: %w", invocation.Args[0], err)
|
||||||
}
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
stopIO := context.AfterFunc(childContext, func() {
|
||||||
|
defer close(done)
|
||||||
|
_ = stdin.Close()
|
||||||
|
_ = stdout.Close()
|
||||||
|
})
|
||||||
return &PipedProcess{
|
return &PipedProcess{
|
||||||
command: command,
|
command: command,
|
||||||
stdin: stdin,
|
stdin: stdin,
|
||||||
stdout: stdout,
|
stdout: stdout,
|
||||||
parent: ctx,
|
parent: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
|
stopIO: stopIO,
|
||||||
|
ioDone: done,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,15 +148,17 @@ func newCommand(ctx context.Context, invocation Invocation) (*exec.Cmd, error) {
|
|||||||
}
|
}
|
||||||
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
||||||
command.Dir = invocation.Dir
|
command.Dir = invocation.Dir
|
||||||
|
command.WaitDelay = time.Second
|
||||||
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
||||||
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
|
// Dir contributes PWD on POSIX before explicit overrides are applied.
|
||||||
|
command.Env = environmentEntries(command.Environ(), invocation.Env, invocation.UnsetEnv)
|
||||||
}
|
}
|
||||||
return command, nil
|
return command, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func environmentEntries(overlay map[string]string, unset []string) []string {
|
func environmentEntries(base []string, overlay map[string]string, unset []string) []string {
|
||||||
environment := make(map[string]string, len(os.Environ())+len(overlay))
|
environment := make(map[string]string, len(base)+len(overlay))
|
||||||
for _, entry := range os.Environ() {
|
for _, entry := range base {
|
||||||
key, value, ok := strings.Cut(entry, "=")
|
key, value, ok := strings.Cut(entry, "=")
|
||||||
if ok {
|
if ok {
|
||||||
environment[key] = value
|
environment[key] = value
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -78,6 +80,50 @@ func TestRunCanRemoveInheritedEnvironment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunDirectoryEnvironmentPreservesExplicitPWDSelection(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("directory-derived PWD is a POSIX behavior")
|
||||||
|
}
|
||||||
|
parent := mustGetwd(t)
|
||||||
|
t.Setenv("PWD", parent)
|
||||||
|
workdir := t.TempDir()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, override, want string
|
||||||
|
unset bool
|
||||||
|
present bool
|
||||||
|
}{
|
||||||
|
{name: "directory", want: workdir, present: true},
|
||||||
|
{name: "override", override: "selected PWD", want: "selected PWD", present: true},
|
||||||
|
{name: "removed", unset: true},
|
||||||
|
{name: "override after removal", unset: true, override: "selected PWD", want: "selected PWD", present: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
invocation := Invocation{
|
||||||
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "pwd"},
|
||||||
|
Dir: workdir,
|
||||||
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
||||||
|
}
|
||||||
|
if test.override != "" {
|
||||||
|
invocation.Env["PWD"] = test.override
|
||||||
|
}
|
||||||
|
if test.unset {
|
||||||
|
invocation.UnsetEnv = []string{"PWD"}
|
||||||
|
}
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
invocation.Stdout = &stdout
|
||||||
|
if err := Run(t.Context(), invocation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := stdout.String(), fmt.Sprintf("%s|%t", test.want, test.present); got != want {
|
||||||
|
t.Fatalf("child PWD = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if os.Getenv("PWD") != parent || mustGetwd(t) != parent {
|
||||||
|
t.Fatal("child invocation changed the parent's directory or PWD")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunReportsMissingExecutable(t *testing.T) {
|
func TestRunReportsMissingExecutable(t *testing.T) {
|
||||||
err := Run(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
|
err := Run(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
|
||||||
if err == nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
|
if err == nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
|
||||||
@@ -119,6 +165,68 @@ func TestRunReturnsCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunBoundsInheritedOutputPipes(t *testing.T) {
|
||||||
|
for _, mode := range []string{"cancel-descendant", "exit-descendant"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
ready := filepath.Join(t.TempDir(), "descendant")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
var output bytes.Buffer
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
result <- Run(ctx, Invocation{
|
||||||
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", mode},
|
||||||
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1", "RELEASEKIT_DESCENDANT_READY": ready},
|
||||||
|
Stdout: &output, Stderr: &output,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
deadline := time.After(10 * time.Second)
|
||||||
|
var pid int
|
||||||
|
for pid == 0 {
|
||||||
|
data, err := os.ReadFile(ready)
|
||||||
|
if err == nil {
|
||||||
|
pid, _ = strconv.Atoi(string(data))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("descendant did not become ready")
|
||||||
|
default:
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if process, err := os.FindProcess(pid); err == nil {
|
||||||
|
_ = process.Kill()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if mode == "cancel-descendant" {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if mode == "cancel-descendant" && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled command error = %v", err)
|
||||||
|
}
|
||||||
|
if mode == "exit-descendant" && !errors.Is(err, exec.ErrWaitDelay) {
|
||||||
|
t.Fatalf("exited command error = %v, want pipe teardown error", err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("command did not release inherited output pipes")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDoesNotLimitHealthyCommandLifetime(t *testing.T) {
|
||||||
|
err := Run(context.Background(), Invocation{
|
||||||
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "slow-success"},
|
||||||
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("healthy command failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
|
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
|
||||||
workdir := filepath.Join(t.TempDir(), "piped directory")
|
workdir := filepath.Join(t.TempDir(), "piped directory")
|
||||||
if err := os.Mkdir(workdir, 0o700); err != nil {
|
if err := os.Mkdir(workdir, 0o700); err != nil {
|
||||||
@@ -197,6 +305,52 @@ func TestStartPipedCancellationAfterReadinessReapsChild(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartPipedCancellationInterruptsInheritedOutputDrain(t *testing.T) {
|
||||||
|
ready := filepath.Join(t.TempDir(), "descendant")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
child, err := StartPiped(ctx, Invocation{
|
||||||
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit-descendant"},
|
||||||
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1", "RELEASEKIT_DESCENDANT_READY": ready},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = child.Stdin().Close()
|
||||||
|
drained := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(io.Discard, child.Stdout())
|
||||||
|
drained <- err
|
||||||
|
}()
|
||||||
|
deadline := time.After(10 * time.Second)
|
||||||
|
var pid int
|
||||||
|
for pid == 0 {
|
||||||
|
if data, err := os.ReadFile(ready); err == nil {
|
||||||
|
pid, _ = strconv.Atoi(string(data))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("descendant did not become ready")
|
||||||
|
default:
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if descendant, err := os.FindProcess(pid); err == nil {
|
||||||
|
_ = descendant.Kill()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-drained:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("inherited stdout did not close after cancellation")
|
||||||
|
}
|
||||||
|
if err := child.Wait(); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("wait after cancellation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartPipedCancelDoesNotCancelParentContext(t *testing.T) {
|
func TestStartPipedCancelDoesNotCancelParentContext(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
child, err := StartPiped(ctx, Invocation{
|
child, err := StartPiped(ctx, Invocation{
|
||||||
@@ -269,6 +423,10 @@ func TestProcessHelper(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mode := args[len(args)-1]
|
mode := args[len(args)-1]
|
||||||
switch mode {
|
switch mode {
|
||||||
|
case "pwd":
|
||||||
|
value, present := os.LookupEnv("PWD")
|
||||||
|
fmt.Fprintf(os.Stdout, "%s|%t", value, present)
|
||||||
|
os.Exit(0)
|
||||||
case "success":
|
case "success":
|
||||||
workdir, err := os.Getwd()
|
workdir, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -279,11 +437,39 @@ func TestProcessHelper(t *testing.T) {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
case "exit":
|
case "exit":
|
||||||
os.Exit(7)
|
os.Exit(7)
|
||||||
|
case "slow-success":
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
os.Exit(0)
|
||||||
case "wait":
|
case "wait":
|
||||||
fmt.Fprintln(os.Stdout, "started")
|
fmt.Fprintln(os.Stdout, "started")
|
||||||
for {
|
for {
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
}
|
}
|
||||||
|
case "cancel-descendant", "exit-descendant":
|
||||||
|
child := exec.Command(os.Args[0], "-test.run=TestProcessHelper", "--", "unused", "descendant")
|
||||||
|
child.Stdout, child.Stderr = os.Stdout, os.Stderr
|
||||||
|
if err := child.Start(); err != nil {
|
||||||
|
os.Exit(14)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(os.Getenv("RELEASEKIT_DESCENDANT_READY")); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if mode == "exit-descendant" {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
case "descendant":
|
||||||
|
if err := os.WriteFile(os.Getenv("RELEASEKIT_DESCENDANT_READY"), []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
|
||||||
|
os.Exit(15)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
case "exchange":
|
case "exchange":
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
request, err := reader.ReadString('\n')
|
request, err := reader.ReadString('\n')
|
||||||
|
|||||||
112
internal/publish/attempts.go
Normal file
112
internal/publish/attempts.go
Normal 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
|
||||||
|
}
|
||||||
147
internal/publish/cancellation_test.go
Normal file
147
internal/publish/cancellation_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
153
internal/publish/reconcile.go
Normal file
153
internal/publish/reconcile.go
Normal 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}
|
||||||
|
}
|
||||||
59
internal/publish/reconcile_test.go
Normal file
59
internal/publish/reconcile_test.go
Normal 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
224
internal/publish/record.go
Normal 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
|
||||||
|
}
|
||||||
537
internal/publish/record_test.go
Normal file
537
internal/publish/record_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,12 +126,6 @@ func selectCommittedDirectory(ctx context.Context, resolver git.PathResolver, mo
|
|||||||
return selection, nil
|
return selection, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromCommittedSelection derives identity from a previously resolved module
|
|
||||||
// selection and the selected module declaration at a captured commit.
|
|
||||||
func FromCommittedSelection(ctx context.Context, selection Selection, version, commit string) (Identity, error) {
|
|
||||||
return fromCommittedSelection(ctx, selection, version, commit, git.CommittedPaths(selection.RepositoryDir, commit))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fromCommittedSelection(ctx context.Context, selection Selection, version, commit string, resolver git.PathResolver) (Identity, error) {
|
func fromCommittedSelection(ctx context.Context, selection Selection, version, commit string, resolver git.PathResolver) (Identity, error) {
|
||||||
identity := Identity{Selection: selection, Version: version}
|
identity := Identity{Selection: selection, Version: version}
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package tempdir
|
package tempdir
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,11 +12,15 @@ import (
|
|||||||
// Outside uses system temporary storage and falls back to the repository's
|
// Outside uses system temporary storage and falls back to the repository's
|
||||||
// parent when TMPDIR resolves inside the worktree. Cleanup belongs to the caller.
|
// parent when TMPDIR resolves inside the worktree. Cleanup belongs to the caller.
|
||||||
func Outside(repositoryDir, pattern string) (string, error) {
|
func Outside(repositoryDir, pattern string) (string, error) {
|
||||||
repository, err := filepath.Abs(repositoryDir)
|
return outside(repositoryDir, pattern, filepath.Abs, filepath.EvalSymlinks, os.RemoveAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func outside(repositoryDir, pattern string, abs, resolve func(string) (string, error), remove func(string) error) (string, error) {
|
||||||
|
repository, err := abs(repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
repository, err = filepath.EvalSymlinks(repository)
|
repository, err = resolve(repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -24,54 +29,54 @@ func Outside(repositoryDir, pattern string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
created := directory
|
created := directory
|
||||||
directory, err = filepath.Abs(created)
|
directory, err = abs(created)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(created)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
directory, err = filepath.EvalSymlinks(directory)
|
directory, err = resolve(directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(directory)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
outside, err := isOutside(repository, directory)
|
outside, err := isOutside(repository, directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(directory)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
if outside {
|
if outside {
|
||||||
return directory, nil
|
return directory, nil
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(directory); err != nil {
|
if err := remove(created); err != nil {
|
||||||
return "", fmt.Errorf("remove temporary storage inside repository %q: %w", repository, err)
|
return "", fmt.Errorf("remove temporary storage %q: %w", created, err)
|
||||||
}
|
}
|
||||||
directory, err = os.MkdirTemp(filepath.Dir(repository), pattern)
|
directory, err = os.MkdirTemp(filepath.Dir(repository), pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("create temporary storage outside repository %q: %w", repository, err)
|
return "", fmt.Errorf("create temporary storage outside repository %q: %w", repository, err)
|
||||||
}
|
}
|
||||||
created = directory
|
created = directory
|
||||||
directory, err = filepath.Abs(created)
|
directory, err = abs(created)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(created)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
directory, err = filepath.EvalSymlinks(directory)
|
directory, err = resolve(directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(directory)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
outside, err = isOutside(repository, directory)
|
outside, err = isOutside(repository, directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(directory)
|
return "", cleanupError(created, err, remove)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
if !outside {
|
if !outside {
|
||||||
os.RemoveAll(directory)
|
return "", cleanupError(created, fmt.Errorf("temporary storage %q is inside repository %q", directory, repository), remove)
|
||||||
return "", fmt.Errorf("temporary storage %q is inside repository %q", directory, repository)
|
|
||||||
}
|
}
|
||||||
return directory, nil
|
return directory, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanupError(path string, cause error, remove func(string) error) error {
|
||||||
|
if err := remove(path); err != nil {
|
||||||
|
return errors.Join(cause, fmt.Errorf("remove temporary storage %q: %w", path, err))
|
||||||
|
}
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
|
||||||
func isOutside(repository, directory string) (bool, error) {
|
func isOutside(repository, directory string) (bool, error) {
|
||||||
if !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
|
if !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|||||||
@@ -1,12 +1,81 @@
|
|||||||
package tempdir
|
package tempdir
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestOutsideRemovesAllocatedPathWhenResolutionFails(t *testing.T) {
|
||||||
|
for _, fallback := range []bool{false, true} {
|
||||||
|
for _, operation := range []string{"absolute", "symlinks"} {
|
||||||
|
name := operation
|
||||||
|
if fallback {
|
||||||
|
name += "-fallback"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
parent := t.TempDir()
|
||||||
|
repository := filepath.Join(parent, "repository")
|
||||||
|
local := filepath.Join(repository, "tmp")
|
||||||
|
if err := os.MkdirAll(local, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fallback {
|
||||||
|
setTemporaryDirectory(t, local)
|
||||||
|
} else {
|
||||||
|
setTemporaryDirectory(t, parent)
|
||||||
|
}
|
||||||
|
resolutionErr := errors.New("resolution failed")
|
||||||
|
removalErr := errors.New("removal failed")
|
||||||
|
calls := 0
|
||||||
|
failed := false
|
||||||
|
var removed string
|
||||||
|
failAt := 2
|
||||||
|
if fallback {
|
||||||
|
failAt = 3
|
||||||
|
}
|
||||||
|
abs := func(path string) (string, error) {
|
||||||
|
if operation == "absolute" {
|
||||||
|
calls++
|
||||||
|
if calls == failAt {
|
||||||
|
failed = true
|
||||||
|
return "", resolutionErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Abs(path)
|
||||||
|
}
|
||||||
|
resolve := func(path string) (string, error) {
|
||||||
|
if operation == "symlinks" {
|
||||||
|
calls++
|
||||||
|
if calls == failAt {
|
||||||
|
failed = true
|
||||||
|
return "", resolutionErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.EvalSymlinks(path)
|
||||||
|
}
|
||||||
|
remove := func(path string) error {
|
||||||
|
if err := os.RemoveAll(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if failed {
|
||||||
|
removed = path
|
||||||
|
return removalErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := outside(repository, "owned-", abs, resolve, remove)
|
||||||
|
if !errors.Is(err, resolutionErr) || !errors.Is(err, removalErr) || removed == "" || !strings.Contains(err.Error(), removed) || !strings.HasPrefix(filepath.Base(removed), "owned-") {
|
||||||
|
t.Fatalf("error = %v, removed = %q", err, removed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOutsideHonorsRelativeTemporaryDirectory(t *testing.T) {
|
func TestOutsideHonorsRelativeTemporaryDirectory(t *testing.T) {
|
||||||
parent := t.TempDir()
|
parent := t.TempDir()
|
||||||
repository := t.TempDir()
|
repository := t.TempDir()
|
||||||
|
|||||||
109
internal/validation/file_size_benchmark_test.go
Normal file
109
internal/validation/file_size_benchmark_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// benchmarkExclusionIndex is an assessment-only alternative, not production logic.
|
||||||
|
func benchmarkExclusionIndex(exclusions []string) map[string]struct{} {
|
||||||
|
index := make(map[string]struct{}, len(exclusions))
|
||||||
|
for _, exclusion := range normalizedExclusions(exclusions) {
|
||||||
|
index[exclusion] = struct{}{}
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchmarkExcluded(filePath string, index map[string]struct{}) bool {
|
||||||
|
if _, ok := index["."]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := index[filePath]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for pos := 0; pos < len(filePath); pos++ {
|
||||||
|
if filePath[pos] == '/' {
|
||||||
|
if _, ok := index[filePath[:pos]]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var benchmarkExclusionMatches int
|
||||||
|
|
||||||
|
func BenchmarkFileSizeExclusions(b *testing.B) {
|
||||||
|
for _, pathCount := range []int{1000, 10000} {
|
||||||
|
for _, exclusionCount := range []int{1, 10, 100, 1000} {
|
||||||
|
for _, depth := range []string{"shallow", "deep"} {
|
||||||
|
for _, match := range []bool{true, false} {
|
||||||
|
exclusions := make([]string, exclusionCount)
|
||||||
|
for i := range exclusions {
|
||||||
|
exclusions[i] = fmt.Sprintf("excluded-%04d", i)
|
||||||
|
}
|
||||||
|
paths := make([]string, pathCount)
|
||||||
|
prefix := "other"
|
||||||
|
if match {
|
||||||
|
prefix = exclusions[0]
|
||||||
|
}
|
||||||
|
if depth == "deep" {
|
||||||
|
prefix += "/nested/one/two/three/four"
|
||||||
|
}
|
||||||
|
for i := range paths {
|
||||||
|
paths[i] = fmt.Sprintf("%s/file-%05d", prefix, i)
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("paths=%d/exclusions=%d/%s/match=%t", pathCount, exclusionCount, depth, match)
|
||||||
|
for _, indexed := range []bool{false, true} {
|
||||||
|
method := "scan"
|
||||||
|
if indexed {
|
||||||
|
method = "index"
|
||||||
|
}
|
||||||
|
b.Run(name+"/"+method, func(b *testing.B) {
|
||||||
|
normalized := normalizedExclusions(exclusions)
|
||||||
|
index := benchmarkExclusionIndex(exclusions)
|
||||||
|
for _, filePath := range paths {
|
||||||
|
if excludedByPath(filePath, normalized) != benchmarkExcluded(filePath, index) {
|
||||||
|
b.Fatalf("index disagrees for %q", filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
count := 0
|
||||||
|
if indexed {
|
||||||
|
index := benchmarkExclusionIndex(exclusions)
|
||||||
|
for _, filePath := range paths {
|
||||||
|
if benchmarkExcluded(filePath, index) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
normalized := normalizedExclusions(exclusions)
|
||||||
|
for _, filePath := range paths {
|
||||||
|
if excludedByPath(filePath, normalized) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
benchmarkExclusionMatches = count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBenchmarkExclusionIndexPreservesLiteralMatching(t *testing.T) {
|
||||||
|
for _, exclusions := range [][]string{{"."}, {"/"}, {"a//b", "../x", "", "a/b"}, {"a", "a/b"}} {
|
||||||
|
index := benchmarkExclusionIndex(exclusions)
|
||||||
|
for _, filePath := range []string{"a", "a/b", "a/b/c", "ab/c", "/", "//file", "../x/file", "other"} {
|
||||||
|
if got, want := benchmarkExcluded(filePath, index), excludedByPath(filePath, normalizedExclusions(exclusions)); got != want {
|
||||||
|
t.Errorf("path %q exclusions %s: indexed %t, scan %t", filePath, strings.Join(exclusions, ","), got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ func TestParseMarkdownHeadingAnchors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarkdownCacheSharesParsedDocumentsByBlobObjectID(t *testing.T) {
|
func TestMarkdownDocumentsParseIdenticalAndDistinctBlobs(t *testing.T) {
|
||||||
repository := newTreeRepository(t)
|
repository := newTreeRepository(t)
|
||||||
shared := []byte("# Guide\n[other](other.md \"title\")\n")
|
shared := []byte("# Guide\n[other](other.md \"title\")\n")
|
||||||
writeTreeFile(t, repository, "one/guide.md", shared)
|
writeTreeFile(t, repository, "one/guide.md", shared)
|
||||||
@@ -111,20 +111,17 @@ func TestMarkdownCacheSharesParsedDocumentsByBlobObjectID(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if first != second {
|
|
||||||
t.Fatal("identical Markdown blobs returned different parsed documents")
|
|
||||||
}
|
|
||||||
other, err := cache.document(context.Background(), "one/other.md")
|
other, err := cache.document(context.Background(), "one/other.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if other == first {
|
|
||||||
t.Fatal("different Markdown blobs shared a parsed document")
|
|
||||||
}
|
|
||||||
if _, ok := other.anchors["other"]; !ok {
|
if _, ok := other.anchors["other"]; !ok {
|
||||||
t.Fatalf("other document anchors = %#v", other.anchors)
|
t.Fatalf("other document anchors = %#v", other.anchors)
|
||||||
}
|
}
|
||||||
if got := first.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
|
if got := first.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
|
||||||
t.Fatalf("shared Markdown link = %#v, want content-relative destination, title, and location", got)
|
t.Fatalf("shared Markdown link = %#v, want content-relative destination, title, and location", got)
|
||||||
}
|
}
|
||||||
|
if got := second.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
|
||||||
|
t.Fatalf("second Markdown link = %#v, want content-relative destination, title, and location", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run executes shared validation for the captured candidate.
|
// Run executes shared validation for the captured candidate.
|
||||||
func Run(ctx context.Context, request Request) (result error) {
|
func Run(ctx context.Context, request Request) error {
|
||||||
if request.Settings.FileSize != nil && request.Settings.FileSize.LimitBytes != nil && *request.Settings.FileSize.LimitBytes < 0 {
|
if request.Settings.FileSize != nil && request.Settings.FileSize.LimitBytes != nil && *request.Settings.FileSize.LimitBytes < 0 {
|
||||||
return &Failure{
|
return &Failure{
|
||||||
Check: FileSize,
|
Check: FileSize,
|
||||||
@@ -89,6 +89,13 @@ func Run(ctx context.Context, request Request) (result error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := runGoChecks(ctx, request); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runTreeChecks(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGoChecks(ctx context.Context, request Request) error {
|
||||||
goDirectory := request.ModuleDir
|
goDirectory := request.ModuleDir
|
||||||
if goDirectory == "" {
|
if goDirectory == "" {
|
||||||
goDirectory = request.RepositoryDir
|
goDirectory = request.RepositoryDir
|
||||||
@@ -134,6 +141,10 @@ func Run(ctx context.Context, request Request) (result error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTreeChecks(ctx context.Context, request Request) (result error) {
|
||||||
needsSharedSelection := len(request.Settings.SharedFiles) != 0
|
needsSharedSelection := len(request.Settings.SharedFiles) != 0
|
||||||
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection || len(request.Settings.ForbiddenFiles) != 0 {
|
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection || len(request.Settings.ForbiddenFiles) != 0 {
|
||||||
tree, err := request.ReadCandidateTree(ctx)
|
tree, err := request.ReadCandidateTree(ctx)
|
||||||
|
|||||||
@@ -3,15 +3,99 @@ package releasekit_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
releasekit "go.maximumdirect.net/releasekit"
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestPrepareTaggedCheckoutRedactsRemoteCredentials(t *testing.T) {
|
||||||
|
t.Setenv("NO_PROXY", "*")
|
||||||
|
t.Setenv("no_proxy", "*")
|
||||||
|
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||||
|
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
var authenticated atomic.Bool
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, password, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="fixture"`)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user != "review-user" || password != "review-password" {
|
||||||
|
t.Error("fetch did not preserve the configured credentials")
|
||||||
|
}
|
||||||
|
authenticated.Store(true)
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
remote := strings.Replace(server.URL, "http://", "http://review-user:review-password@", 1) + "/repo.git"
|
||||||
|
err := releasekit.PrepareTaggedCheckout(t.Context(), releasekit.TaggedCheckoutRequest{
|
||||||
|
RepositoryDir: repo.work, Tag: "v1.2.3", Remote: remote, ExpectedCommit: repo.commit,
|
||||||
|
})
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || !authenticated.Load() || !strings.Contains(err.Error(), "v1.2.3") || !strings.Contains(err.Error(), "repo.git") {
|
||||||
|
t.Fatalf("preparation failure = %v, authenticated = %t", err, authenticated.Load())
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "review-user") || strings.Contains(err.Error(), "review-password") {
|
||||||
|
t.Fatalf("preparation diagnostic exposes credentials: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareBuildDependenciesCancellationReleasesInheritedOutput(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
ready := filepath.Join(t.TempDir(), "descendant")
|
||||||
|
gitPath := filepath.Join(t.TempDir(), "git")
|
||||||
|
if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nsleep 60 &\nprintf '%s' \"$!\" > \"$RELEASEKIT_PREPARE_DESCENDANT\"\nwait\n"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", filepath.Dir(gitPath)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("RELEASEKIT_PREPARE_DESCENDANT", ready)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
result <- releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{RepositoryDir: directory})
|
||||||
|
}()
|
||||||
|
deadline := time.After(10 * time.Second)
|
||||||
|
var pid int
|
||||||
|
for pid == 0 {
|
||||||
|
if data, err := os.ReadFile(ready); err == nil {
|
||||||
|
pid, _ = strconv.Atoi(string(data))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("preparation descendant did not start")
|
||||||
|
default:
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if descendant, err := os.FindProcess(pid); err == nil {
|
||||||
|
_ = descendant.Kill()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("preparation error = %v, want cancellation", err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("preparation did not return after cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) {
|
func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) {
|
||||||
repository := newTaggedDeliveryConsumer(t)
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
tagCommit := publishPreparationTag(t, repository)
|
tagCommit := publishPreparationTag(t, repository)
|
||||||
|
|||||||
48
publish.go
48
publish.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
246
publish_test.go
246
publish_test.go
@@ -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
153
recovery.go
Normal 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}
|
||||||
|
}
|
||||||
21
recovery_conversion_test.go
Normal file
21
recovery_conversion_test.go
Normal 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
72
recovery_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user